diff --git a/messages/en-US.json b/messages/en-US.json index 9bef781..d905c15 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -456,6 +456,60 @@ "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", "following": "Following", diff --git a/package.json b/package.json index 8e65c27..08bfb91 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.3", + "jszip": "^3.10.1", "lucide-react": "^0.562.0", "next": "16.1.1", "next-intl": "^4.7.0", @@ -27,6 +28,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", + "sql.js": "^1.14.1", "tailwind-merge": "^3.4.0", "unstorage": "^1.17.3", "winston": "^3.19.0", @@ -41,6 +43,7 @@ "@types/nodemailer": "^7.0.11", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/sql.js": "^1.4.9", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "babel-plugin-react-compiler": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c00bb6..e41c4a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: version: 3.0.3 better-auth: 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: specifier: ^0.7.1 version: 0.7.1 @@ -33,6 +33,9 @@ importers: dotenv: specifier: ^17.2.3 version: 17.2.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -60,6 +63,9 @@ importers: sonner: specifier: ^2.0.7 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: specifier: ^3.4.0 version: 3.4.0 @@ -78,7 +84,7 @@ importers: devDependencies: '@better-auth/cli': 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': specifier: ^3.3.3 version: 3.3.3 @@ -97,6 +103,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.7) + '@types/sql.js': + specifier: ^1.4.9 + version: 1.4.9 '@typescript-eslint/eslint-plugin': 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) @@ -1046,6 +1055,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1072,6 +1084,9 @@ packages: '@types/react@19.2.7': 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': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -1620,6 +1635,9 @@ packages: cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2218,6 +2236,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2366,6 +2387,9 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2417,6 +2441,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2442,6 +2469,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -2762,6 +2792,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2900,6 +2933,9 @@ packages: typescript: optional: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -2945,6 +2981,9 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3005,6 +3044,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3053,6 +3095,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3111,6 +3156,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + sqlstring@2.3.3: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} @@ -3151,6 +3199,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3699,7 +3750,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@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: '@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 '@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 - 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 c12: 3.3.2 chalk: 5.6.2 commander: 12.1.0 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 pg: 8.16.3 prettier: 3.7.4 @@ -4411,6 +4462,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/emscripten@1.41.5': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -4439,6 +4492,11 @@ snapshots: dependencies: 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': {} '@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: {} - 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: '@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: '@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@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 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 @@ -4835,7 +4893,7 @@ snapshots: 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: '@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: '@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@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 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 @@ -5034,6 +5092,8 @@ snapshots: cookie-es@1.2.2: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5125,12 +5185,13 @@ snapshots: 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: '@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 @@ -5138,6 +5199,7 @@ snapshots: 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 dunder-proto@1.0.1: dependencies: @@ -5685,6 +5747,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5837,6 +5901,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -5881,6 +5947,13 @@ snapshots: object.assign: 4.1.7 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: dependencies: json-buffer: 3.0.1 @@ -5902,6 +5975,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -6204,6 +6281,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6333,6 +6412,8 @@ snapshots: - react - react-dom + process-nextick-args@2.0.1: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -6384,6 +6465,16 @@ snapshots: 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: dependencies: inherits: 2.0.4 @@ -6452,6 +6543,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -6501,6 +6594,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -6590,6 +6685,8 @@ snapshots: split2@4.2.0: {} + sql.js@1.14.1: {} + sqlstring@2.3.3: {} stable-hash@0.0.5: {} @@ -6653,6 +6750,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 diff --git a/src/app/(auth)/profile/page.tsx b/src/app/(auth)/profile/page.tsx index 05f8826..0eaec1b 100644 --- a/src/app/(auth)/profile/page.tsx +++ b/src/app/(auth)/profile/page.tsx @@ -9,5 +9,5 @@ export default async function ProfilePage() { redirect("/login?redirect=/profile"); } - redirect(session.user.username ? `/users/${session.user.username}` : "/folders"); + redirect(session.user.username ? `/users/${session.user.username}` : "/decks"); } diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 081d091..a2b6e89 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -28,7 +28,7 @@ export default function SignUpPage() { useEffect(() => { if (!isPending && session?.user?.username && !redirectTo && !verificationSent) { - router.push("/folders"); + router.push("/decks"); } }, [session, isPending, router, redirectTo, verificationSent]); diff --git a/src/app/decks/DecksClient.tsx b/src/app/decks/DecksClient.tsx index c6bf01b..5d2d4a6 100644 --- a/src/app/decks/DecksClient.tsx +++ b/src/app/decks/DecksClient.tsx @@ -25,6 +25,7 @@ import { actionGetDeckById, } from "@/modules/deck/deck-action"; import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto"; +import { ImportButton, ExportButton } from "@/components/deck/ImportExport"; interface DeckCardProps { deck: ActionOutputDeck; @@ -149,25 +150,17 @@ export function DecksClient({ userId }: DecksClientProps) { const [decks, setDecks] = useState([]); 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(() => { - 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(); - - return () => { - ignore = true; - }; }, [userId]); const handleUpdateDeck = (deckId: number, updates: Partial) => { @@ -199,11 +192,12 @@ export function DecksClient({ userId }: DecksClientProps) { -
+
{t("newDeck")} +
diff --git a/src/components/deck/ImportExport.tsx b/src/components/deck/ImportExport.tsx new file mode 100644 index 0000000..06f53ff --- /dev/null +++ b/src/components/deck/ImportExport.tsx @@ -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(null); + const [decks, setDecks] = useState([]); + const [selectedDeckId, setSelectedDeckId] = useState(null); + const [deckName, setDeckName] = useState(""); + const [loading, setLoading] = useState(false); + const fileInputRef = useRef(null); + + const handleFileSelect = async (e: React.ChangeEvent) => { + 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 ( + <> + setIsModalOpen(true)}> + + {t("importApkg")} + + + +
+
+

{t("importApkg")}

+ +
+ + {step === "upload" && ( +
+
fileInputRef.current?.click()} + > + +

{t("clickToUpload")}

+

{t("apkgFilesOnly")}

+
+ + {loading && ( +
+ + {t("parsing")} +
+ )} +
+ )} + + {step === "select" && ( +
+

{t("foundDecks", { count: decks.length })}

+ +
+ {decks.map((deck) => ( +
{ + setSelectedDeckId(deck.id); + setDeckName(deck.name); + }} + > +
+ {deck.name} + {deck.cardCount} cards +
+
+ ))} +
+ +
+ + 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")} + /> +
+ +
+ setStep("upload")} className="flex-1"> + {t("back")} + + + {t("import")} + +
+
+ )} + + {step === "importing" && ( +
+ +

{t("importing")}

+
+ )} +
+
+ + ); +} + +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 ( + + {loading ? : } + {t("exportApkg")} + + ); +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 1fc4956..30358f1 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -21,7 +21,7 @@ export async function Navbar() { }); const mobileMenuItems: NavigationItem[] = [ - { label: t("folders"), href: "/folders", icon: }, + { label: t("folders"), href: "/decks", icon: }, { label: t("explore"), href: "/explore", icon: }, ...(session ? [{ label: t("favorites"), href: "/favorites", icon: }] : []), { label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: , external: true }, @@ -42,7 +42,7 @@ export async function Navbar() {
- + {t("folders")} diff --git a/src/lib/anki/apkg-exporter.ts b/src/lib/anki/apkg-exporter.ts new file mode 100644 index 0000000..be21c09 --- /dev/null +++ b/src/lib/anki/apkg-exporter.ts @@ -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; + }; + 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; +} + +async function createDatabase(data: ExportDeckData): Promise { + 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 = { + [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 = { + [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 = { + "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 { + const zip = new JSZip(); + + const dbData = await createDatabase(data); + zip.file("collection.anki21", dbData); + + const mediaMapping: Record = {}; + 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" }); +} diff --git a/src/lib/anki/apkg-parser.ts b/src/lib/anki/apkg-parser.ts new file mode 100644 index 0000000..659f698 --- /dev/null +++ b/src/lib/anki/apkg-parser.ts @@ -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 { + 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(jsonStr: string): T { + try { + return JSON.parse(jsonStr); + } catch { + return {} as T; + } +} + +function queryAll(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(db: Database, sql: string, params: SqlValue[] = []): T | null { + const results = queryAll(db, sql, params); + return results[0] ?? null; +} + +export async function parseApkg(buffer: Buffer): Promise { + 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(); + const decksJson = parseJsonField>(col.decks); + for (const [id, deck] of Object.entries(decksJson)) { + decksMap.set(parseInt(id, 10), deck); + } + + const noteTypesMap = new Map(); + const modelsJson = parseJsonField>(col.models); + for (const [id, model] of Object.entries(modelsJson)) { + noteTypesMap.set(parseInt(id, 10), model); + } + + const deckConfigsMap = new Map(); + const dconfJson = parseJsonField>(col.dconf); + for (const [id, config] of Object.entries(dconfJson)) { + deckConfigsMap.set(parseInt(id, 10), config); + } + + const notes = queryAll( + db, + "SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes" + ); + + const cards = queryAll( + 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( + db, + "SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog" + ); + + const mediaMap = new Map(); + const mediaFile = zip.file("media"); + if (mediaFile) { + const mediaJson = parseJsonField>(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(); + 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)); +} diff --git a/src/lib/anki/types.ts b/src/lib/anki/types.ts new file mode 100644 index 0000000..46acf9b --- /dev/null +++ b/src/lib/anki/types.ts @@ -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; + noteTypes: Map; + deckConfigs: Map; + notes: AnkiNoteRow[]; + cards: AnkiCardRow[]; + revlogs: AnkiRevlogRow[]; + media: Map; + collectionMeta: { + crt: number; + mod: number; + ver: number; + }; +} + +export interface ApkgImportResult { + success: boolean; + deckName: string; + noteCount: number; + cardCount: number; + mediaCount: number; + errors: string[]; +} diff --git a/src/modules/export/export-action.ts b/src/modules/export/export-action.ts new file mode 100644 index 0000000..cc45311 --- /dev/null +++ b/src/modules/export/export-action.ts @@ -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 { + 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, + }, + 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", + }; + } +} diff --git a/src/modules/import/import-action.ts b/src/modules/import/import-action.ts new file mode 100644 index 0000000..fc09054 --- /dev/null +++ b/src/modules/import/import-action.ts @@ -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; + +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 { + 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(); + const firstNote = ankiNotes[0]; + if (firstNote) { + const importedNoteTypeId = await importNoteType(parsed, firstNote.mid, userId); + noteTypeIdMap.set(firstNote.mid, importedNoteTypeId); + } + + const noteIdMap = new Map(); + + 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 { + 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 { + 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" + }; + } +}