feat: 添加 Anki APKG 导入/导出功能
- 添加 APKG 解析器 (src/lib/anki/apkg-parser.ts) - 添加 APKG 导出器 (src/lib/anki/apkg-exporter.ts) - 添加导入/导出 Server Actions - 添加导入/导出 UI 组件 - 集成到牌组页面 - 添加 i18n 翻译 同时修复断链: - /folders → /decks (Navbar, signup, profile)
This commit is contained in:
@@ -456,6 +456,60 @@
|
|||||||
"view": "View"
|
"view": "View"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Decks",
|
||||||
|
"subtitle": "Manage your flashcard decks",
|
||||||
|
"newDeck": "New Deck",
|
||||||
|
"enterDeckName": "Enter deck name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
|
"noDecksYet": "No decks yet",
|
||||||
|
"deckInfo": "ID: {id} • {totalCards} cards",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private",
|
||||||
|
"setPublic": "Set Public",
|
||||||
|
"setPrivate": "Set Private",
|
||||||
|
"enterNewName": "Enter new name:",
|
||||||
|
"importApkg": "Import APKG",
|
||||||
|
"exportApkg": "Export APKG",
|
||||||
|
"clickToUpload": "Click to upload .apkg file",
|
||||||
|
"apkgFilesOnly": "APKG files only",
|
||||||
|
"parsing": "Parsing file...",
|
||||||
|
"foundDecks": "Found {count} deck(s)",
|
||||||
|
"deckName": "Deck Name",
|
||||||
|
"back": "Back",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"exportSuccess": "Deck exported successfully",
|
||||||
|
"goToDecks": "Go to Decks"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Decks",
|
||||||
|
"subtitle": "Manage your flashcard decks",
|
||||||
|
"newDeck": "New Deck",
|
||||||
|
"noDecksYet": "No decks yet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"deckInfo": "ID: {id} • {totalCards} cards",
|
||||||
|
"enterDeckName": "Enter deck name:",
|
||||||
|
"enterNewName": "Enter new name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private",
|
||||||
|
"setPublic": "Set Public",
|
||||||
|
"setPrivate": "Set Private",
|
||||||
|
"importApkg": "Import APKG",
|
||||||
|
"exportApkg": "Export APKG",
|
||||||
|
"clickToUpload": "Click to upload an APKG file",
|
||||||
|
"apkgFilesOnly": "Only .apkg files are supported",
|
||||||
|
"parsing": "Parsing...",
|
||||||
|
"foundDecks": "Found {count} deck(s)",
|
||||||
|
"deckName": "Deck Name",
|
||||||
|
"back": "Back",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"exportSuccess": "Deck exported successfully",
|
||||||
|
"goToDecks": "Go to Decks"
|
||||||
|
},
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "Follow",
|
"follow": "Follow",
|
||||||
"following": "Following",
|
"following": "Following",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-intl": "^4.7.0",
|
"next-intl": "^4.7.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sql.js": "^1.14.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||||
"@typescript-eslint/parser": "^8.51.0",
|
"@typescript-eslint/parser": "^8.51.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
|||||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -23,7 +23,7 @@ importers:
|
|||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -33,6 +33,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.3
|
specifier: ^17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
|
jszip:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
@@ -60,6 +63,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
sql.js:
|
||||||
|
specifier: ^1.14.1
|
||||||
|
version: 1.14.1
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -78,7 +84,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@better-auth/cli':
|
'@better-auth/cli':
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
@@ -97,6 +103,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3(@types/react@19.2.7)
|
version: 19.2.3(@types/react@19.2.7)
|
||||||
|
'@types/sql.js':
|
||||||
|
specifier: ^1.4.9
|
||||||
|
version: 1.4.9
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^8.51.0
|
specifier: ^8.51.0
|
||||||
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
@@ -1046,6 +1055,9 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5':
|
||||||
|
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1072,6 +1084,9 @@ packages:
|
|||||||
'@types/react@19.2.7':
|
'@types/react@19.2.7':
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.9':
|
||||||
|
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
@@ -1620,6 +1635,9 @@ packages:
|
|||||||
cookie-es@1.2.2:
|
cookie-es@1.2.2:
|
||||||
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2218,6 +2236,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immediate@3.0.6:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2366,6 +2387,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
@@ -2417,6 +2441,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2442,6 +2469,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2762,6 +2792,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pako@1.0.11:
|
||||||
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2900,6 +2933,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2945,6 +2981,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3005,6 +3044,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-buffer@5.2.1:
|
safe-buffer@5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
@@ -3053,6 +3095,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
@@ -3111,6 +3156,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
sql.js@1.14.1:
|
||||||
|
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
|
||||||
|
|
||||||
sqlstring@2.3.3:
|
sqlstring@2.3.3:
|
||||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3151,6 +3199,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
@@ -3699,7 +3750,7 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
||||||
@@ -3711,13 +3762,13 @@ snapshots:
|
|||||||
'@mrleebo/prisma-ast': 0.13.1
|
'@mrleebo/prisma-ast': 0.13.1
|
||||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
c12: 3.3.2
|
c12: 3.3.2
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.3
|
dotenv: 17.2.3
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
prettier: 3.7.4
|
prettier: 3.7.4
|
||||||
@@ -4411,6 +4462,8 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -4439,6 +4492,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/emscripten': 1.41.5
|
||||||
|
'@types/node': 25.0.3
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
@@ -4810,7 +4868,7 @@ snapshots:
|
|||||||
|
|
||||||
bcryptjs@3.0.3: {}
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
@@ -4827,7 +4885,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
@@ -4835,7 +4893,7 @@ snapshots:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
@@ -4852,7 +4910,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
@@ -5034,6 +5092,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie-es@1.2.2: {}
|
cookie-es@1.2.2: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5125,12 +5185,13 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3):
|
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@electric-sql/pglite': 0.3.15
|
'@electric-sql/pglite': 0.3.15
|
||||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
'@types/sql.js': 1.4.9
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
kysely: 0.28.8
|
kysely: 0.28.8
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
@@ -5138,6 +5199,7 @@ snapshots:
|
|||||||
postgres: 3.4.7
|
postgres: 3.4.7
|
||||||
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
sql.js: 1.14.1
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5685,6 +5747,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -5837,6 +5901,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-inside-container: 1.0.0
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
@@ -5881,6 +5947,13 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
dependencies:
|
||||||
|
lie: 3.3.0
|
||||||
|
pako: 1.0.11
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -5902,6 +5975,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6204,6 +6281,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
pako@1.0.11: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -6333,6 +6412,8 @@ snapshots:
|
|||||||
- react
|
- react
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
@@ -6384,6 +6465,16 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.3: {}
|
react@19.2.3: {}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@@ -6452,6 +6543,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
isarray: 2.0.5
|
isarray: 2.0.5
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
@@ -6501,6 +6594,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.0.0
|
'@img/colour': 1.0.0
|
||||||
@@ -6590,6 +6685,8 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
sql.js@1.14.1: {}
|
||||||
|
|
||||||
sqlstring@2.3.3: {}
|
sqlstring@2.3.3: {}
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
@@ -6653,6 +6750,10 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default async function ProfilePage() {
|
|||||||
redirect("/login?redirect=/profile");
|
redirect("/login?redirect=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
redirect(session.user.username ? `/users/${session.user.username}` : "/decks");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function SignUpPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
|
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
|
||||||
router.push("/folders");
|
router.push("/decks");
|
||||||
}
|
}
|
||||||
}, [session, isPending, router, redirectTo, verificationSent]);
|
}, [session, isPending, router, redirectTo, verificationSent]);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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;
|
||||||
@@ -149,25 +150,17 @@ export function DecksClient({ userId }: DecksClientProps) {
|
|||||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadDecks = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await actionGetDecksByUserId(userId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setDecks(result.data);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
|
||||||
|
|
||||||
const loadDecks = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const result = await actionGetDecksByUserId(userId);
|
|
||||||
if (!ignore) {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setDecks(result.data);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadDecks();
|
loadDecks();
|
||||||
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
||||||
@@ -199,11 +192,12 @@ export function DecksClient({ userId }: DecksClientProps) {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex gap-2">
|
||||||
<LightButton onClick={handleCreateDeck}>
|
<LightButton onClick={handleCreateDeck}>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
{t("newDeck")}
|
{t("newDeck")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
<ImportButton onImportComplete={loadDecks} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardList>
|
<CardList>
|
||||||
|
|||||||
254
src/components/deck/ImportExport.tsx
Normal file
254
src/components/deck/ImportExport.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export async function Navbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mobileMenuItems: NavigationItem[] = [
|
const mobileMenuItems: NavigationItem[] = [
|
||||||
{ label: t("folders"), href: "/folders", icon: <Folder size={18} /> },
|
{ label: t("folders"), href: "/decks", icon: <Folder size={18} /> },
|
||||||
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
||||||
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
||||||
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
||||||
@@ -42,7 +42,7 @@ export async function Navbar() {
|
|||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<div className="flex gap-0.5 justify-center items-center">
|
<div className="flex gap-0.5 justify-center items-center">
|
||||||
<LanguageSettings />
|
<LanguageSettings />
|
||||||
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
|
<GhostLightButton href="/decks" className="md:block! hidden!" size="md">
|
||||||
{t("folders")}
|
{t("folders")}
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
||||||
|
|||||||
419
src/lib/anki/apkg-exporter.ts
Normal file
419
src/lib/anki/apkg-exporter.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import JSZip from "jszip";
|
||||||
|
import initSqlJs from "sql.js";
|
||||||
|
import type { Database } from "sql.js";
|
||||||
|
import type {
|
||||||
|
AnkiDeck,
|
||||||
|
AnkiNoteType,
|
||||||
|
AnkiDeckConfig,
|
||||||
|
AnkiNoteRow,
|
||||||
|
AnkiCardRow,
|
||||||
|
AnkiRevlogRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const FIELD_SEPARATOR = "\x1f";
|
||||||
|
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
|
||||||
|
|
||||||
|
function generateGuid(): string {
|
||||||
|
let result = "";
|
||||||
|
const id = Date.now() ^ (Math.random() * 0xffffffff);
|
||||||
|
let num = BigInt(id);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result = BASE91_CHARS[Number(num % 91n)] + result;
|
||||||
|
num = num / 91n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksum(str: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return Math.abs(hash) % 100000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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,
|
||||||
|
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: {
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbData = db.export();
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
return dbData;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
174
src/lib/anki/apkg-parser.ts
Normal file
174
src/lib/anki/apkg-parser.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
let 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 results: T[] = [];
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
stmt.bind(params);
|
||||||
|
|
||||||
|
while (stmt.step()) {
|
||||||
|
const row = stmt.getAsObject();
|
||||||
|
results.push(row as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt.free();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
db.close();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
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));
|
||||||
|
}
|
||||||
193
src/lib/anki/types.ts
Normal file
193
src/lib/anki/types.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
134
src/modules/export/export-action.ts
Normal file
134
src/modules/export/export-action.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
296
src/modules/import/import-action.ts
Normal file
296
src/modules/import/import-action.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { validate } from "@/utils/validate";
|
||||||
|
import { z } from "zod";
|
||||||
|
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 type { ParsedApkg } from "@/lib/anki/types";
|
||||||
|
|
||||||
|
const log = createLogger("import-action");
|
||||||
|
|
||||||
|
const schemaImportApkg = z.object({
|
||||||
|
deckName: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ActionInputImportApkg = z.infer<typeof schemaImportApkg>;
|
||||||
|
|
||||||
|
export interface ActionOutputImportApkg {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
deckId?: number;
|
||||||
|
noteCount?: number;
|
||||||
|
cardCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionOutputPreviewApkg {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
decks?: { id: number; name: string; 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: fields as unknown as object,
|
||||||
|
templates: templates as unknown as object,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDeck(
|
||||||
|
parsed: ParsedApkg,
|
||||||
|
deckId: number,
|
||||||
|
userId: string,
|
||||||
|
deckNameOverride?: string
|
||||||
|
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
|
||||||
|
const ankiDeck = parsed.decks.get(deckId);
|
||||||
|
if (!ankiDeck) {
|
||||||
|
throw new Error(`Deck ${deckId} not found in APKG`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deck = await prisma.deck.create({
|
||||||
|
data: {
|
||||||
|
name: deckNameOverride || ankiDeck.name,
|
||||||
|
desc: ankiDeck.desc || "",
|
||||||
|
visibility: "PRIVATE",
|
||||||
|
collapsed: ankiDeck.collapsed,
|
||||||
|
conf: JSON.parse(JSON.stringify(ankiDeck)),
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, deckId);
|
||||||
|
|
||||||
|
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> {
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const parsed = await parseApkg(buffer);
|
||||||
|
const decks = getDeckNames(parsed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "APKG parsed successfully",
|
||||||
|
decks: decks.filter(d => d.cardCount > 0)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to parse 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 deckId = parseInt(deckIdStr, 10);
|
||||||
|
if (isNaN(deckId)) {
|
||||||
|
return { success: false, message: "Invalid deck ID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const parsed = await parseApkg(buffer);
|
||||||
|
|
||||||
|
const result = await importDeck(parsed, deckId, 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user