diff --git a/package.json b/package.json
index 3479c2c..c16b700 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "arkvault",
"private": true,
- "version": "0.6.0",
+ "version": "0.7.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -17,11 +17,12 @@
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
+ "@eslint/js": "^9.39.2",
"@iconify-json/material-symbols": "^1.2.50",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
- "@tanstack/svelte-virtual": "^3.13.13",
+ "@tanstack/svelte-virtual": "^3.13.16",
"@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
@@ -49,7 +50,7 @@
"svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
- "typescript-eslint": "^8.50.1",
+ "typescript-eslint": "^8.51.0",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
},
@@ -63,7 +64,7 @@
"pg": "^8.16.3",
"superjson": "^2.2.6",
"uuid": "^13.0.0",
- "zod": "^4.2.1"
+ "zod": "^4.3.5"
},
"engines": {
"node": "^22.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9dcf04f..e4e336f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -36,12 +36,15 @@ importers:
specifier: ^13.0.0
version: 13.0.0
zod:
- specifier: ^4.2.1
- version: 4.2.1
+ specifier: ^4.3.5
+ version: 4.3.5
devDependencies:
'@eslint/compat':
specifier: ^2.0.0
version: 2.0.0(eslint@9.39.2(jiti@1.21.7))
+ '@eslint/js':
+ specifier: ^9.39.2
+ version: 9.39.2
'@iconify-json/material-symbols':
specifier: ^1.2.50
version: 1.2.50
@@ -55,8 +58,8 @@ importers:
specifier: ^6.2.1
version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0))
'@tanstack/svelte-virtual':
- specifier: ^3.13.13
- version: 3.13.13(svelte@5.46.1)
+ specifier: ^3.13.16
+ version: 3.13.16(svelte@5.46.1)
'@trpc/client':
specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)
@@ -139,8 +142,8 @@ importers:
specifier: ^5.9.3
version: 5.9.3
typescript-eslint:
- specifier: ^8.50.1
- version: 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ specifier: ^8.51.0
+ version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
unplugin-icons:
specifier: ^22.5.0
version: 22.5.0(svelte@5.46.1)
@@ -316,8 +319,8 @@ packages:
cpu: [x64]
os: [win32]
- '@eslint-community/eslint-utils@4.9.0':
- resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
@@ -617,13 +620,13 @@ packages:
svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0
- '@tanstack/svelte-virtual@3.13.13':
- resolution: {integrity: sha512-VDOvbRw3R+XBQdFodEJ4E7AOmEyo3Bmr4zL4DLVnJ0fxICdbvY5F5t8zSwJ4f7lqjckXi0yKFzY8WBtjaNbsGQ==}
+ '@tanstack/svelte-virtual@3.13.16':
+ resolution: {integrity: sha512-LRDPRzAPTIiDjiCA9lhNlFnZRLj/XsNhzNRsT5JEA8hzcBmZw8avdYYVjydPAy0ObFJgG1zBAm9Dtvwqju36sg==}
peerDependencies:
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
- '@tanstack/virtual-core@3.13.13':
- resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==}
+ '@tanstack/virtual-core@3.13.16':
+ resolution: {integrity: sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==}
'@trpc/client@11.8.1':
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
@@ -663,63 +666,63 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
- '@typescript-eslint/eslint-plugin@8.50.1':
- resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==}
+ '@typescript-eslint/eslint-plugin@8.51.0':
+ resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.50.1
+ '@typescript-eslint/parser': ^8.51.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/parser@8.50.1':
- resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==}
+ '@typescript-eslint/parser@8.51.0':
+ resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/project-service@8.50.1':
- resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==}
+ '@typescript-eslint/project-service@8.51.0':
+ resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/scope-manager@8.50.1':
- resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==}
+ '@typescript-eslint/scope-manager@8.51.0':
+ resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.50.1':
- resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==}
+ '@typescript-eslint/tsconfig-utils@8.51.0':
+ resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/type-utils@8.50.1':
- resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==}
+ '@typescript-eslint/type-utils@8.51.0':
+ resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/types@8.50.1':
- resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==}
+ '@typescript-eslint/types@8.51.0':
+ resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.50.1':
- resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==}
+ '@typescript-eslint/typescript-estree@8.51.0':
+ resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/utils@8.50.1':
- resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==}
+ '@typescript-eslint/utils@8.51.0':
+ resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/visitor-keys@8.50.1':
- resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==}
+ '@typescript-eslint/visitor-keys@8.51.0':
+ resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@xmldom/xmldom@0.9.8':
@@ -827,8 +830,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
- caniuse-lite@1.0.30001761:
- resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==}
+ caniuse-lite@1.0.30001762:
+ resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1039,8 +1042,8 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- esquery@1.6.0:
- resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
esrap@2.2.1:
@@ -1854,8 +1857,8 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
- ts-api-utils@2.1.0:
- resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
+ ts-api-utils@2.4.0:
+ resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
@@ -1877,8 +1880,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
- typescript-eslint@8.50.1:
- resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
+ typescript-eslint@8.51.0:
+ resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -2022,8 +2025,8 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
- zod@4.2.1:
- resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
+ zod@4.3.5:
+ resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
snapshots:
@@ -2114,7 +2117,7 @@ snapshots:
'@esbuild/win32-x64@0.27.2':
optional: true
- '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))':
dependencies:
eslint: 9.39.2(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
@@ -2386,12 +2389,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/svelte-virtual@3.13.13(svelte@5.46.1)':
+ '@tanstack/svelte-virtual@3.13.16(svelte@5.46.1)':
dependencies:
- '@tanstack/virtual-core': 3.13.13
+ '@tanstack/virtual-core': 3.13.16
svelte: 5.46.1
- '@tanstack/virtual-core@3.13.13': {}
+ '@tanstack/virtual-core@3.13.16': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
@@ -2428,95 +2431,95 @@ snapshots:
'@types/resolve@1.20.2': {}
- '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.50.1
- '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.50.1
+ '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.51.0
+ '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.51.0
eslint: 9.39.2(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
- ts-api-utils: 2.1.0(typescript@5.9.3)
+ ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.50.1
- '@typescript-eslint/types': 8.50.1
- '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.50.1
+ '@typescript-eslint/scope-manager': 8.51.0
+ '@typescript-eslint/types': 8.51.0
+ '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.51.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.51.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.50.1':
+ '@typescript-eslint/scope-manager@8.51.0':
dependencies:
- '@typescript-eslint/types': 8.50.1
- '@typescript-eslint/visitor-keys': 8.50.1
+ '@typescript-eslint/types': 8.51.0
+ '@typescript-eslint/visitor-keys': 8.51.0
- '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.50.1
- '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.51.0
+ '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
- ts-api-utils: 2.1.0(typescript@5.9.3)
+ ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.50.1': {}
+ '@typescript-eslint/types@8.51.0': {}
- '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.50.1
- '@typescript-eslint/visitor-keys': 8.50.1
+ '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.51.0
+ '@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
- ts-api-utils: 2.1.0(typescript@5.9.3)
+ ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7))
- '@typescript-eslint/scope-manager': 8.50.1
- '@typescript-eslint/types': 8.50.1
- '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.51.0
+ '@typescript-eslint/types': 8.51.0
+ '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.50.1':
+ '@typescript-eslint/visitor-keys@8.51.0':
dependencies:
- '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/types': 8.51.0
eslint-visitor-keys: 4.2.1
'@xmldom/xmldom@0.9.8':
@@ -2564,7 +2567,7 @@ snapshots:
autoprefixer@10.4.23(postcss@8.5.6):
dependencies:
browserslist: 4.28.1
- caniuse-lite: 1.0.30001761
+ caniuse-lite: 1.0.30001762
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.6
@@ -2602,7 +2605,7 @@ snapshots:
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.11
- caniuse-lite: 1.0.30001761
+ caniuse-lite: 1.0.30001762
electron-to-chromium: 1.5.267
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -2631,7 +2634,7 @@ snapshots:
camelcase-css@2.0.1: {}
- caniuse-lite@1.0.30001761: {}
+ caniuse-lite@1.0.30001762: {}
chalk@4.1.2:
dependencies:
@@ -2795,7 +2798,7 @@ snapshots:
eslint-plugin-svelte@3.13.1(eslint@9.39.2(jiti@1.21.7))(svelte@5.46.1):
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
eslint: 9.39.2(jiti@1.21.7)
esutils: 2.0.3
@@ -2828,7 +2831,7 @@ snapshots:
eslint@9.39.2(jiti@1.21.7):
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2
@@ -2848,7 +2851,7 @@ snapshots:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
- esquery: 1.6.0
+ esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
@@ -2875,7 +2878,7 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
- esquery@1.6.0:
+ esquery@1.7.0:
dependencies:
estraverse: 5.3.0
@@ -3601,7 +3604,7 @@ snapshots:
totalist@3.0.1: {}
- ts-api-utils@2.1.0(typescript@5.9.3):
+ ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -3615,12 +3618,12 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- typescript-eslint@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
+ typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
@@ -3704,4 +3707,4 @@ snapshots:
zimmerframe@1.1.4: {}
- zod@4.2.1: {}
+ zod@4.3.5: {}
diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte
new file mode 100644
index 0000000..67a684d
--- /dev/null
+++ b/src/lib/components/atoms/RowVirtualizer.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
+
+ {@render item(virtualItem.index)}
+
+ {/each}
+
+ {#if placeholder && count === 0}
+ {@render placeholder()}
+ {/if}
+
diff --git a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte
index c18101c..6c5632c 100644
--- a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte
+++ b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte
@@ -1,42 +1,24 @@
-{#if $info}
-
-{/if}
+
diff --git a/src/lib/components/atoms/index.ts b/src/lib/components/atoms/index.ts
index 14b0849..61a0238 100644
--- a/src/lib/components/atoms/index.ts
+++ b/src/lib/components/atoms/index.ts
@@ -3,3 +3,4 @@ export * from "./buttons";
export * from "./divs";
export * from "./inputs";
export { default as Modal } from "./Modal.svelte";
+export { default as RowVirtualizer } from "./RowVirtualizer.svelte";
diff --git a/src/lib/components/molecules/Categories.svelte b/src/lib/components/molecules/Categories.svelte
new file mode 100644
index 0000000..72fe7de
--- /dev/null
+++ b/src/lib/components/molecules/Categories.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+{#if categoriesWithName.length > 0}
+
+ {#each categoriesWithName as category (category.id)}
+
onCategoryClick(category)}
+ actionButtonIcon={categoryMenuIcon}
+ onActionButtonClick={() => onCategoryMenuClick?.(category)}
+ >
+
+
+ {/each}
+
+{/if}
diff --git a/src/lib/components/molecules/Categories/Categories.svelte b/src/lib/components/molecules/Categories/Categories.svelte
deleted file mode 100644
index 54368c6..0000000
--- a/src/lib/components/molecules/Categories/Categories.svelte
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-{#if categoriesWithName.length > 0}
-
- {#each categoriesWithName as { info }}
-
- {/each}
-
-{/if}
diff --git a/src/lib/components/molecules/Categories/Category.svelte b/src/lib/components/molecules/Categories/Category.svelte
deleted file mode 100644
index aab227c..0000000
--- a/src/lib/components/molecules/Categories/Category.svelte
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-{#if $info}
-
-
-
-{/if}
diff --git a/src/lib/components/molecules/Categories/index.ts b/src/lib/components/molecules/Categories/index.ts
deleted file mode 100644
index d8a70c2..0000000
--- a/src/lib/components/molecules/Categories/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from "./Categories.svelte";
-export * from "./service";
diff --git a/src/lib/components/molecules/Categories/service.ts b/src/lib/components/molecules/Categories/service.ts
deleted file mode 100644
index 08c41db..0000000
--- a/src/lib/components/molecules/Categories/service.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export interface SelectedCategory {
- id: number;
- dataKey: CryptoKey;
- dataKeyVersion: Date;
- name: string;
-}
diff --git a/src/lib/components/molecules/SubCategories.svelte b/src/lib/components/molecules/SubCategories.svelte
index 9c84a89..6db38f1 100644
--- a/src/lib/components/molecules/SubCategories.svelte
+++ b/src/lib/components/molecules/SubCategories.svelte
@@ -1,10 +1,8 @@
@@ -53,14 +43,12 @@
{#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()}
{/if}
- {#key info}
-
- {/key}
+
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}
diff --git a/src/lib/components/molecules/index.ts b/src/lib/components/molecules/index.ts
index 8edc84a..a36afcd 100644
--- a/src/lib/components/molecules/index.ts
+++ b/src/lib/components/molecules/index.ts
@@ -1,7 +1,7 @@
export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte";
-export * from "./Categories";
-export { default as Categories } from "./Categories";
+export * from "./Categories.svelte";
+export { default as Categories } from "./Categories.svelte";
export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte";
diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte
deleted file mode 100644
index ce3abcd..0000000
--- a/src/lib/components/organisms/Category/Category.svelte
+++ /dev/null
@@ -1,107 +0,0 @@
-
-
-
-
- {#if info.id !== "root"}
-
하위 카테고리
- {/if}
-
-
- {#if info.id !== "root"}
-
-
-
- {#key info}
- {#each files as { info, isRecursive }}
-
- {:else}
-
이 카테고리에 추가된 파일이 없어요.
- {/each}
- {/key}
-
-
- {/if}
-
diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte
deleted file mode 100644
index 8e3fc12..0000000
--- a/src/lib/components/organisms/Category/File.svelte
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-{#if $info}
-
-
-
-{/if}
diff --git a/src/lib/components/organisms/Category/index.ts b/src/lib/components/organisms/Category/index.ts
deleted file mode 100644
index 51e0a58..0000000
--- a/src/lib/components/organisms/Category/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from "./Category.svelte";
-export * from "./service";
diff --git a/src/lib/components/organisms/Category/service.ts b/src/lib/components/organisms/Category/service.ts
deleted file mode 100644
index fb6e640..0000000
--- a/src/lib/components/organisms/Category/service.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export { requestFileThumbnailDownload } from "$lib/services/file";
-
-export interface SelectedFile {
- id: number;
- dataKey: CryptoKey;
- dataKeyVersion: Date;
- name: string;
-}
diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte
deleted file mode 100644
index 8537ac5..0000000
--- a/src/lib/components/organisms/Gallery.svelte
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
-
- {#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
- {@const row = rows[virtualRow.index]!}
-
- {#if row.type === "header"}
-
{row.label}
- {:else}
-
- {#each row.items as { info }}
-
- {/each}
-
- {/if}
-
- {/each}
-
- {#if $virtualizer.getVirtualItems().length === 0}
-
-
- {#if files.length === 0}
- 업로드된 파일이 없어요.
- {:else if filesWithDate.length === 0}
- 파일 목록을 불러오고 있어요.
- {:else}
- 사진 또는 동영상이 없어요.
- {/if}
-
-
- {/if}
-
diff --git a/src/lib/components/organisms/index.ts b/src/lib/components/organisms/index.ts
index 9687bfe..fa02317 100644
--- a/src/lib/components/organisms/index.ts
+++ b/src/lib/components/organisms/index.ts
@@ -1,4 +1 @@
-export * from "./Category";
-export { default as Category } from "./Category";
-export { default as Gallery } from "./Gallery.svelte";
export * from "./modals";
diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts
index cf60b93..87c0d70 100644
--- a/src/lib/indexedDB/filesystem.ts
+++ b/src/lib/indexedDB/filesystem.ts
@@ -1,7 +1,5 @@
import { Dexie, type EntityTable } from "dexie";
-export type DirectoryId = "root" | number;
-
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
@@ -15,17 +13,15 @@ interface FileInfo {
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
- categoryIds: number[];
+ categoryIds?: number[];
}
-export type CategoryId = "root" | number;
-
interface CategoryInfo {
id: number;
parentId: CategoryId;
name: string;
- files: { id: number; isRecursive: boolean }[];
- isFileRecursive: boolean;
+ files?: { id: number; isRecursive: boolean }[];
+ isFileRecursive?: boolean;
}
const filesystem = new Dexie("filesystem") as Dexie & {
@@ -59,13 +55,23 @@ export const getDirectoryInfo = async (id: number) => {
};
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
- await filesystem.directory.put(directoryInfo);
+ await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo });
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
+export const deleteDanglingDirectoryInfos = async (
+ parentId: DirectoryId,
+ validIds: Set
,
+) => {
+ await filesystem.directory
+ .where({ parentId })
+ .and((directory) => !validIds.has(directory.id))
+ .delete();
+};
+
export const getAllFileInfos = async () => {
return await filesystem.file.toArray();
};
@@ -78,14 +84,29 @@ export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id);
};
+export const bulkGetFileInfos = async (ids: number[]) => {
+ return await filesystem.file.bulkGet(ids);
+};
+
export const storeFileInfo = async (fileInfo: FileInfo) => {
- await filesystem.file.put(fileInfo);
+ await filesystem.file.upsert(fileInfo.id, { ...fileInfo });
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
+export const bulkDeleteFileInfos = async (ids: number[]) => {
+ await filesystem.file.bulkDelete(ids);
+};
+
+export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set) => {
+ await filesystem.file
+ .where({ parentId })
+ .and((file) => !validIds.has(file.id))
+ .delete();
+};
+
export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray();
};
@@ -95,7 +116,7 @@ export const getCategoryInfo = async (id: number) => {
};
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
- await filesystem.category.put(categoryInfo);
+ await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo });
};
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
@@ -106,6 +127,13 @@ export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id);
};
+export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set) => {
+ await filesystem.category
+ .where({ parentId })
+ .and((category) => !validIds.has(category.id))
+ .delete();
+};
+
export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = [];
const validFileIds: number[] = [];
diff --git a/src/lib/modules/file/cache.ts b/src/lib/modules/file/cache.ts
index ccb187e..fe3c66c 100644
--- a/src/lib/modules/file/cache.ts
+++ b/src/lib/modules/file/cache.ts
@@ -1,15 +1,12 @@
-import { LRUCache } from "lru-cache";
import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex,
deleteFileCacheIndex,
type FileCacheIndex,
} from "$lib/indexedDB";
-import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
-import { getThumbnailUrl } from "$lib/modules/thumbnail";
+import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
const fileCacheIndex = new Map();
-const loadedThumbnails = new LRUCache({ max: 100 });
export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) {
@@ -51,30 +48,3 @@ export const deleteFileCache = async (fileId: number) => {
await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId);
};
-
-export const getFileThumbnailCache = async (fileId: number) => {
- const thumbnail = loadedThumbnails.get(fileId);
- if (thumbnail) return thumbnail;
-
- const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
- if (!thumbnailBuffer) return null;
-
- const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
- loadedThumbnails.set(fileId, thumbnailUrl);
- return thumbnailUrl;
-};
-
-export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
- await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
- loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
-};
-
-export const deleteFileThumbnailCache = async (fileId: number) => {
- loadedThumbnails.delete(fileId);
- await deleteFile(`/thumbnail/file/${fileId}`);
-};
-
-export const deleteAllFileThumbnailCaches = async () => {
- loadedThumbnails.clear();
- await deleteDirectory("/thumbnail/file");
-};
diff --git a/src/lib/modules/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts
new file mode 100644
index 0000000..bea8316
--- /dev/null
+++ b/src/lib/modules/file/download.svelte.ts
@@ -0,0 +1,95 @@
+import axios from "axios";
+import { limitFunction } from "p-limit";
+import { decryptData } from "$lib/modules/crypto";
+
+export interface FileDownloadState {
+ id: number;
+ status:
+ | "download-pending"
+ | "downloading"
+ | "decryption-pending"
+ | "decrypting"
+ | "decrypted"
+ | "canceled"
+ | "error";
+ progress?: number;
+ rate?: number;
+ estimated?: number;
+ result?: ArrayBuffer;
+}
+
+type LiveFileDownloadState = FileDownloadState & {
+ status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
+};
+
+let downloadingFiles: FileDownloadState[] = $state([]);
+
+export const isFileDownloading = (
+ status: FileDownloadState["status"],
+): status is LiveFileDownloadState["status"] =>
+ ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
+
+export const getFileDownloadState = (fileId: number) => {
+ return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
+};
+
+export const getDownloadingFiles = () => {
+ return downloadingFiles.filter((file) => isFileDownloading(file.status));
+};
+
+export const clearDownloadedFiles = () => {
+ downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
+};
+
+const requestFileDownload = limitFunction(
+ async (state: FileDownloadState, id: number) => {
+ state.status = "downloading";
+
+ const res = await axios.get(`/api/file/${id}/download`, {
+ responseType: "arraybuffer",
+ onDownloadProgress: ({ progress, rate, estimated }) => {
+ state.progress = progress;
+ state.rate = rate;
+ state.estimated = estimated;
+ },
+ });
+ const fileEncrypted: ArrayBuffer = res.data;
+
+ state.status = "decryption-pending";
+ return fileEncrypted;
+ },
+ { concurrency: 1 },
+);
+
+const decryptFile = limitFunction(
+ async (
+ state: FileDownloadState,
+ fileEncrypted: ArrayBuffer,
+ fileEncryptedIv: string,
+ dataKey: CryptoKey,
+ ) => {
+ state.status = "decrypting";
+
+ const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
+
+ state.status = "decrypted";
+ state.result = fileBuffer;
+ return fileBuffer;
+ },
+ { concurrency: 4 },
+);
+
+export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
+ downloadingFiles.push({
+ id,
+ status: "download-pending",
+ });
+ const state = downloadingFiles.at(-1)!;
+
+ try {
+ return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
+ } catch (e) {
+ state.status = "error";
+ throw e;
+ }
+};
diff --git a/src/lib/modules/file/download.ts b/src/lib/modules/file/download.ts
deleted file mode 100644
index b0efb30..0000000
--- a/src/lib/modules/file/download.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import axios from "axios";
-import { limitFunction } from "p-limit";
-import { writable, type Writable } from "svelte/store";
-import { decryptData } from "$lib/modules/crypto";
-import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
-
-const requestFileDownload = limitFunction(
- async (status: Writable, id: number) => {
- status.update((value) => {
- value.status = "downloading";
- return value;
- });
-
- const res = await axios.get(`/api/file/${id}/download`, {
- responseType: "arraybuffer",
- onDownloadProgress: ({ progress, rate, estimated }) => {
- status.update((value) => {
- value.progress = progress;
- value.rate = rate;
- value.estimated = estimated;
- return value;
- });
- },
- });
- const fileEncrypted: ArrayBuffer = res.data;
-
- status.update((value) => {
- value.status = "decryption-pending";
- return value;
- });
- return fileEncrypted;
- },
- { concurrency: 1 },
-);
-
-const decryptFile = limitFunction(
- async (
- status: Writable,
- fileEncrypted: ArrayBuffer,
- fileEncryptedIv: string,
- dataKey: CryptoKey,
- ) => {
- status.update((value) => {
- value.status = "decrypting";
- return value;
- });
-
- const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
-
- status.update((value) => {
- value.status = "decrypted";
- value.result = fileBuffer;
- return value;
- });
- return fileBuffer;
- },
- { concurrency: 4 },
-);
-
-export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
- const status = writable({
- id,
- status: "download-pending",
- });
- fileDownloadStatusStore.update((value) => {
- value.push(status);
- return value;
- });
-
- try {
- return await decryptFile(
- status,
- await requestFileDownload(status, id),
- fileEncryptedIv,
- dataKey,
- );
- } catch (e) {
- status.update((value) => {
- value.status = "error";
- return value;
- });
- throw e;
- }
-};
diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts
index 42a5613..9e9ce0c 100644
--- a/src/lib/modules/file/index.ts
+++ b/src/lib/modules/file/index.ts
@@ -1,3 +1,4 @@
export * from "./cache";
-export * from "./download";
-export * from "./upload";
+export * from "./download.svelte";
+export * from "./thumbnail";
+export * from "./upload.svelte";
diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts
new file mode 100644
index 0000000..f923153
--- /dev/null
+++ b/src/lib/modules/file/thumbnail.ts
@@ -0,0 +1,90 @@
+import { LRUCache } from "lru-cache";
+import { writable, type Writable } from "svelte/store";
+import { browser } from "$app/environment";
+import { decryptData } from "$lib/modules/crypto";
+import type { SummarizedFileInfo } from "$lib/modules/filesystem";
+import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
+import { getThumbnailUrl } from "$lib/modules/thumbnail";
+import { isTRPCClientError, trpc } from "$trpc/client";
+
+const loadedThumbnails = new LRUCache>({ max: 100 });
+const loadingThumbnails = new Map>();
+
+const fetchFromOpfs = async (fileId: number) => {
+ const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
+ if (thumbnailBuffer) {
+ return getThumbnailUrl(thumbnailBuffer);
+ }
+};
+
+const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
+ try {
+ const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
+ fetch(`/api/file/${fileId}/thumbnail/download`),
+ trpc().file.thumbnail.query({ id: fileId }),
+ ]);
+ const thumbnailBuffer = await decryptData(
+ await thumbnailEncrypted.arrayBuffer(),
+ thumbnailEncryptedIv,
+ dataKey,
+ );
+
+ void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
+ return getThumbnailUrl(thumbnailBuffer);
+ } catch (e) {
+ if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
+ return null;
+ }
+ throw e;
+ }
+};
+
+export const getFileThumbnail = (file: SummarizedFileInfo) => {
+ if (
+ !browser ||
+ !(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
+ ) {
+ return undefined;
+ }
+
+ const thumbnail = loadedThumbnails.get(file.id);
+ if (thumbnail) return thumbnail;
+
+ let loadingThumbnail = loadingThumbnails.get(file.id);
+ if (loadingThumbnail) return loadingThumbnail;
+
+ loadingThumbnail = writable(undefined);
+ loadingThumbnails.set(file.id, loadingThumbnail);
+
+ fetchFromOpfs(file.id)
+ .then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
+ .then((thumbnail) => {
+ if (thumbnail) {
+ loadingThumbnail.set(thumbnail);
+ loadedThumbnails.set(file.id, loadingThumbnail as Writable);
+ }
+ loadingThumbnails.delete(file.id);
+ });
+ return loadingThumbnail;
+};
+
+export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
+ await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
+
+ const oldThumbnail = loadedThumbnails.get(fileId);
+ if (oldThumbnail) {
+ oldThumbnail.set(getThumbnailUrl(thumbnailBuffer));
+ } else {
+ loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer)));
+ }
+};
+
+export const deleteFileThumbnailCache = async (fileId: number) => {
+ loadedThumbnails.delete(fileId);
+ await deleteFile(`/thumbnail/file/${fileId}`);
+};
+
+export const deleteAllFileThumbnailCaches = async () => {
+ loadedThumbnails.clear();
+ await deleteDirectory(`/thumbnail/file`);
+};
diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.svelte.ts
similarity index 54%
rename from src/lib/modules/file/upload.ts
rename to src/lib/modules/file/upload.svelte.ts
index 31aabd8..a632eb5 100644
--- a/src/lib/modules/file/upload.ts
+++ b/src/lib/modules/file/upload.svelte.ts
@@ -1,7 +1,6 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
-import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
@@ -11,20 +10,56 @@ import {
digestMessage,
signMessageHmac,
} from "$lib/modules/crypto";
+import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
} from "$lib/server/schemas";
-import {
- fileUploadStatusStore,
- type MasterKey,
- type HmacSecret,
- type FileUploadStatus,
-} from "$lib/stores";
+import type { MasterKey, HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client";
+export interface FileUploadState {
+ name: string;
+ parentId: DirectoryId;
+ status:
+ | "queued"
+ | "encryption-pending"
+ | "encrypting"
+ | "upload-pending"
+ | "uploading"
+ | "uploaded"
+ | "canceled"
+ | "error";
+ progress?: number;
+ rate?: number;
+ estimated?: number;
+}
+
+export type LiveFileUploadState = FileUploadState & {
+ status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
+};
+
+const scheduler = new Scheduler<
+ { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
+>();
+let uploadingFiles: FileUploadState[] = $state([]);
+
+const isFileUploading = (status: FileUploadState["status"]) =>
+ ["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
+
+export const getUploadingFiles = (parentId?: DirectoryId) => {
+ return uploadingFiles.filter(
+ (file) =>
+ (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
+ );
+};
+
+export const clearUploadedFiles = () => {
+ uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
+};
+
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise) => {
const fileBuffer = await file.arrayBuffer();
@@ -76,16 +111,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
};
const encryptFile = limitFunction(
- async (
- status: Writable,
- file: File,
- fileBuffer: ArrayBuffer,
- masterKey: MasterKey,
- ) => {
- status.update((value) => {
- value.status = "encrypting";
- return value;
- });
+ async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => {
+ state.status = "encrypting";
const fileType = getFileType(file);
@@ -109,10 +136,7 @@ const encryptFile = limitFunction(
const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
- status.update((value) => {
- value.status = "upload-pending";
- return value;
- });
+ state.status = "upload-pending";
return {
dataKeyWrapped,
@@ -130,20 +154,14 @@ const encryptFile = limitFunction(
);
const requestFileUpload = limitFunction(
- async (status: Writable, form: FormData, thumbnailForm: FormData | null) => {
- status.update((value) => {
- value.status = "uploading";
- return value;
- });
+ async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => {
+ state.status = "uploading";
const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
- status.update((value) => {
- value.progress = progress;
- value.rate = rate;
- value.estimated = estimated;
- return value;
- });
+ state.progress = progress;
+ state.rate = rate;
+ state.estimated = estimated;
},
});
const { file }: FileUploadResponse = res.data;
@@ -157,10 +175,7 @@ const requestFileUpload = limitFunction(
}
}
- status.update((value) => {
- value.status = "uploaded";
- return value;
- });
+ state.status = "uploaded";
return { fileId: file };
},
@@ -173,92 +188,82 @@ export const uploadFile = async (
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise,
-): Promise<
- { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
-> => {
- const status = writable({
+) => {
+ uploadingFiles.push({
name: file.name,
parentId,
- status: "encryption-pending",
- });
- fileUploadStatusStore.update((value) => {
- value.push(status);
- return value;
+ status: "queued",
});
+ const state = uploadingFiles.at(-1)!;
- try {
- const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
- file,
- hmacSecret,
- onDuplicate,
- );
- if (!fileBuffer || !fileSigned) {
- status.update((value) => {
- value.status = "canceled";
- return value;
- });
- fileUploadStatusStore.update((value) => {
- value = value.filter((v) => v !== status);
- return value;
- });
- return undefined;
- }
+ return await scheduler.schedule(file.size, async () => {
+ state.status = "encryption-pending";
- const {
- dataKeyWrapped,
- dataKeyVersion,
- fileType,
- fileEncrypted,
- fileEncryptedHash,
- nameEncrypted,
- createdAtEncrypted,
- lastModifiedAtEncrypted,
- thumbnail,
- } = await encryptFile(status, file, fileBuffer, masterKey);
+ try {
+ const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
+ file,
+ hmacSecret,
+ onDuplicate,
+ );
+ if (!fileBuffer || !fileSigned) {
+ state.status = "canceled";
+ uploadingFiles = uploadingFiles.filter((file) => file !== state);
+ return undefined;
+ }
- const form = new FormData();
- form.set(
- "metadata",
- JSON.stringify({
- parent: parentId,
- mekVersion: masterKey.version,
- dek: dataKeyWrapped,
- dekVersion: dataKeyVersion.toISOString(),
- hskVersion: hmacSecret.version,
- contentHmac: fileSigned,
- contentType: fileType,
- contentIv: fileEncrypted.iv,
- name: nameEncrypted.ciphertext,
- nameIv: nameEncrypted.iv,
- createdAt: createdAtEncrypted?.ciphertext,
- createdAtIv: createdAtEncrypted?.iv,
- lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
- lastModifiedAtIv: lastModifiedAtEncrypted.iv,
- } satisfies FileUploadRequest),
- );
- form.set("content", new Blob([fileEncrypted.ciphertext]));
- form.set("checksum", fileEncryptedHash);
+ const {
+ dataKeyWrapped,
+ dataKeyVersion,
+ fileType,
+ fileEncrypted,
+ fileEncryptedHash,
+ nameEncrypted,
+ createdAtEncrypted,
+ lastModifiedAtEncrypted,
+ thumbnail,
+ } = await encryptFile(state, file, fileBuffer, masterKey);
- let thumbnailForm = null;
- if (thumbnail) {
- thumbnailForm = new FormData();
- thumbnailForm.set(
+ const form = new FormData();
+ form.set(
"metadata",
JSON.stringify({
+ parent: parentId,
+ mekVersion: masterKey.version,
+ dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
- contentIv: thumbnail.iv,
- } satisfies FileThumbnailUploadRequest),
+ hskVersion: hmacSecret.version,
+ contentHmac: fileSigned,
+ contentType: fileType,
+ contentIv: fileEncrypted.iv,
+ name: nameEncrypted.ciphertext,
+ nameIv: nameEncrypted.iv,
+ createdAt: createdAtEncrypted?.ciphertext,
+ createdAtIv: createdAtEncrypted?.iv,
+ lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
+ lastModifiedAtIv: lastModifiedAtEncrypted.iv,
+ } satisfies FileUploadRequest),
);
- thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
- }
+ form.set("content", new Blob([fileEncrypted.ciphertext]));
+ form.set("checksum", fileEncryptedHash);
- const { fileId } = await requestFileUpload(status, form, thumbnailForm);
- return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
- } catch (e) {
- status.update((value) => {
- value.status = "error";
- return value;
- });
- throw e;
- }
+ let thumbnailForm = null;
+ if (thumbnail) {
+ thumbnailForm = new FormData();
+ thumbnailForm.set(
+ "metadata",
+ JSON.stringify({
+ dekVersion: dataKeyVersion.toISOString(),
+ contentIv: thumbnail.iv,
+ } satisfies FileThumbnailUploadRequest),
+ );
+ thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
+ }
+
+ const { fileId } = await requestFileUpload(state, form, thumbnailForm);
+ return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
+ } catch (e) {
+ state.status = "error";
+ throw e;
+ }
+ });
};
diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts
deleted file mode 100644
index 9f447bf..0000000
--- a/src/lib/modules/filesystem.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-import { TRPCClientError } from "@trpc/client";
-import { get, writable, type Writable } from "svelte/store";
-import {
- getDirectoryInfos as getDirectoryInfosFromIndexedDB,
- getDirectoryInfo as getDirectoryInfoFromIndexedDB,
- storeDirectoryInfo,
- deleteDirectoryInfo,
- getFileInfos as getFileInfosFromIndexedDB,
- getFileInfo as getFileInfoFromIndexedDB,
- storeFileInfo,
- deleteFileInfo,
- getCategoryInfos as getCategoryInfosFromIndexedDB,
- getCategoryInfo as getCategoryInfoFromIndexedDB,
- storeCategoryInfo,
- updateCategoryInfo as updateCategoryInfoInIndexedDB,
- deleteCategoryInfo,
- type DirectoryId,
- type CategoryId,
-} from "$lib/indexedDB";
-import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
-import { trpc } from "$trpc/client";
-
-export type DirectoryInfo =
- | {
- id: "root";
- parentId?: undefined;
- dataKey?: undefined;
- dataKeyVersion?: undefined;
- name?: undefined;
- subDirectoryIds: number[];
- fileIds: number[];
- }
- | {
- id: number;
- parentId: DirectoryId;
- dataKey?: CryptoKey;
- dataKeyVersion?: Date;
- name: string;
- subDirectoryIds: number[];
- fileIds: number[];
- };
-
-export interface FileInfo {
- id: number;
- parentId: DirectoryId;
- dataKey?: CryptoKey;
- dataKeyVersion?: Date;
- contentType: string;
- contentIv?: string;
- name: string;
- createdAt?: Date;
- lastModifiedAt: Date;
- categoryIds: number[];
-}
-
-export type CategoryInfo =
- | {
- id: "root";
- dataKey?: undefined;
- dataKeyVersion?: undefined;
- name?: undefined;
- subCategoryIds: number[];
- files?: undefined;
- isFileRecursive?: undefined;
- }
- | {
- id: number;
- dataKey?: CryptoKey;
- dataKeyVersion?: Date;
- name: string;
- subCategoryIds: number[];
- files: { id: number; isRecursive: boolean }[];
- isFileRecursive: boolean;
- };
-
-const directoryInfoStore = new Map>();
-const fileInfoStore = new Map>();
-const categoryInfoStore = new Map>();
-
-const fetchDirectoryInfoFromIndexedDB = async (
- id: DirectoryId,
- info: Writable,
-) => {
- if (get(info)) return;
-
- const [directory, subDirectories, files] = await Promise.all([
- id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
- getDirectoryInfosFromIndexedDB(id),
- getFileInfosFromIndexedDB(id),
- ]);
- const subDirectoryIds = subDirectories.map(({ id }) => id);
- const fileIds = files.map(({ id }) => id);
-
- if (id === "root") {
- info.set({ id, subDirectoryIds, fileIds });
- } else {
- if (!directory) return;
- info.set({
- id,
- parentId: directory.parentId,
- name: directory.name,
- subDirectoryIds,
- fileIds,
- });
- }
-};
-
-const fetchDirectoryInfoFromServer = async (
- id: DirectoryId,
- info: Writable,
- masterKey: CryptoKey,
-) => {
- let data;
- try {
- data = await trpc().directory.get.query({ id });
- } catch (e) {
- if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
- info.set(null);
- await deleteDirectoryInfo(id as number);
- return;
- }
- throw new Error("Failed to fetch directory information");
- }
-
- const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
-
- if (id === "root") {
- info.set({ id, subDirectoryIds, fileIds });
- } else {
- const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
- const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
-
- info.set({
- id,
- parentId: metadata!.parent,
- dataKey,
- dataKeyVersion: new Date(metadata!.dekVersion),
- name,
- subDirectoryIds,
- fileIds,
- });
- await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
- }
-};
-
-const fetchDirectoryInfo = async (
- id: DirectoryId,
- info: Writable,
- masterKey: CryptoKey,
-) => {
- await fetchDirectoryInfoFromIndexedDB(id, info);
- await fetchDirectoryInfoFromServer(id, info, masterKey);
-};
-
-export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
- // TODO: MEK rotation
-
- let info = directoryInfoStore.get(id);
- if (!info) {
- info = writable(null);
- directoryInfoStore.set(id, info);
- }
-
- fetchDirectoryInfo(id, info, masterKey); // Intended
- return info;
-};
-
-const fetchFileInfoFromIndexedDB = async (id: number, info: Writable) => {
- if (get(info)) return;
-
- const file = await getFileInfoFromIndexedDB(id);
- if (!file) return;
-
- info.set(file);
-};
-
-const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
- return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
-};
-
-const fetchFileInfoFromServer = async (
- id: number,
- info: Writable,
- masterKey: CryptoKey,
-) => {
- let metadata;
- try {
- metadata = await trpc().file.get.query({ id });
- } catch (e) {
- if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
- info.set(null);
- await deleteFileInfo(id);
- return;
- }
- throw new Error("Failed to fetch file information");
- }
- const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
-
- const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
- const createdAt =
- metadata.createdAt && metadata.createdAtIv
- ? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
- : undefined;
- const lastModifiedAt = await decryptDate(
- metadata.lastModifiedAt,
- metadata.lastModifiedAtIv,
- dataKey,
- );
-
- info.set({
- id,
- parentId: metadata.parent,
- dataKey,
- dataKeyVersion: new Date(metadata.dekVersion),
- contentType: metadata.contentType,
- contentIv: metadata.contentIv,
- name,
- createdAt,
- lastModifiedAt,
- categoryIds: metadata.categories,
- });
- await storeFileInfo({
- id,
- parentId: metadata.parent,
- name,
- contentType: metadata.contentType,
- createdAt,
- lastModifiedAt,
- categoryIds: metadata.categories,
- });
-};
-
-const fetchFileInfo = async (id: number, info: Writable, masterKey: CryptoKey) => {
- await fetchFileInfoFromIndexedDB(id, info);
- await fetchFileInfoFromServer(id, info, masterKey);
-};
-
-export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
- // TODO: MEK rotation
-
- let info = fileInfoStore.get(fileId);
- if (!info) {
- info = writable(null);
- fileInfoStore.set(fileId, info);
- }
-
- fetchFileInfo(fileId, info, masterKey); // Intended
- return info;
-};
-
-const fetchCategoryInfoFromIndexedDB = async (
- id: CategoryId,
- info: Writable,
-) => {
- if (get(info)) return;
-
- const [category, subCategories] = await Promise.all([
- id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
- getCategoryInfosFromIndexedDB(id),
- ]);
- const subCategoryIds = subCategories.map(({ id }) => id);
-
- if (id === "root") {
- info.set({ id, subCategoryIds });
- } else {
- if (!category) return;
- info.set({
- id,
- name: category.name,
- subCategoryIds,
- files: category.files,
- isFileRecursive: category.isFileRecursive,
- });
- }
-};
-
-const fetchCategoryInfoFromServer = async (
- id: CategoryId,
- info: Writable,
- masterKey: CryptoKey,
-) => {
- let data;
- try {
- data = await trpc().category.get.query({ id });
- } catch (e) {
- if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
- info.set(null);
- await deleteCategoryInfo(id as number);
- return;
- }
- throw new Error("Failed to fetch category information");
- }
-
- const { metadata, subCategories } = data;
-
- if (id === "root") {
- info.set({ id, subCategoryIds: subCategories });
- } else {
- const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
- const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
-
- let files;
- try {
- files = await trpc().category.files.query({ id, recurse: true });
- } catch {
- throw new Error("Failed to fetch category files");
- }
-
- const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
- let isFileRecursive: boolean | undefined = undefined;
-
- info.update((value) => {
- const newValue = {
- isFileRecursive: false,
- ...value,
- id,
- dataKey,
- dataKeyVersion: new Date(metadata!.dekVersion),
- name,
- subCategoryIds: subCategories,
- files: filesMapped,
- };
- isFileRecursive = newValue.isFileRecursive;
- return newValue;
- });
- await storeCategoryInfo({
- id,
- parentId: metadata!.parent,
- name,
- files: filesMapped,
- isFileRecursive: isFileRecursive!,
- });
- }
-};
-
-const fetchCategoryInfo = async (
- id: CategoryId,
- info: Writable,
- masterKey: CryptoKey,
-) => {
- await fetchCategoryInfoFromIndexedDB(id, info);
- await fetchCategoryInfoFromServer(id, info, masterKey);
-};
-
-export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => {
- // TODO: MEK rotation
-
- let info = categoryInfoStore.get(categoryId);
- if (!info) {
- info = writable(null);
- categoryInfoStore.set(categoryId, info);
- }
-
- fetchCategoryInfo(categoryId, info, masterKey); // Intended
- return info;
-};
-
-export const updateCategoryInfo = async (
- categoryId: number,
- changes: { isFileRecursive?: boolean },
-) => {
- await updateCategoryInfoInIndexedDB(categoryId, changes);
- categoryInfoStore.get(categoryId)?.update((value) => {
- if (!value) return value;
- if (changes.isFileRecursive !== undefined) {
- value.isFileRecursive = changes.isFileRecursive;
- }
- return value;
- });
-};
diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts
new file mode 100644
index 0000000..778f75c
--- /dev/null
+++ b/src/lib/modules/filesystem/category.ts
@@ -0,0 +1,121 @@
+import * as IndexedDB from "$lib/indexedDB";
+import { trpc, isTRPCClientError } from "$trpc/client";
+import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
+import type { CategoryInfo, MaybeCategoryInfo } from "./types";
+
+const cache = new FilesystemCache({
+ async fetchFromIndexedDB(id) {
+ const [category, subCategories] = await Promise.all([
+ id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
+ IndexedDB.getCategoryInfos(id),
+ ]);
+ const files = category?.files
+ ? await Promise.all(
+ category.files.map(async (file) => {
+ const fileInfo = await IndexedDB.getFileInfo(file.id);
+ return fileInfo
+ ? {
+ id: file.id,
+ parentId: fileInfo.parentId,
+ contentType: fileInfo.contentType,
+ name: fileInfo.name,
+ createdAt: fileInfo.createdAt,
+ lastModifiedAt: fileInfo.lastModifiedAt,
+ isRecursive: file.isRecursive,
+ }
+ : undefined;
+ }),
+ )
+ : undefined;
+
+ if (id === "root") {
+ return {
+ id,
+ exists: true,
+ subCategories,
+ };
+ } else if (category) {
+ return {
+ id,
+ exists: true,
+ parentId: category.parentId,
+ name: category.name,
+ subCategories,
+ files: files?.filter((file) => !!file) ?? [],
+ isFileRecursive: category.isFileRecursive ?? false,
+ };
+ }
+ },
+
+ async fetchFromServer(id, cachedInfo, masterKey) {
+ try {
+ const category = await trpc().category.get.query({ id, recurse: true });
+ const [subCategories, files, metadata] = await Promise.all([
+ Promise.all(
+ category.subCategories.map(async (category) => ({
+ id: category.id,
+ parentId: id,
+ ...(await decryptCategoryMetadata(category, masterKey)),
+ })),
+ ),
+ category.files &&
+ Promise.all(
+ category.files.map(async (file) => ({
+ id: file.id,
+ parentId: file.parent,
+ contentType: file.contentType,
+ isRecursive: file.isRecursive,
+ ...(await decryptFileMetadata(file, masterKey)),
+ })),
+ ),
+ category.metadata && decryptCategoryMetadata(category.metadata, masterKey),
+ ]);
+
+ return storeToIndexedDB(
+ id !== "root"
+ ? {
+ id,
+ parentId: category.metadata!.parent,
+ subCategories,
+ files: files!,
+ isFileRecursive: cachedInfo?.isFileRecursive ?? false,
+ ...metadata!,
+ }
+ : { id, subCategories },
+ );
+ } catch (e) {
+ if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
+ await IndexedDB.deleteCategoryInfo(id as number);
+ return { id, exists: false };
+ }
+ throw e;
+ }
+ },
+});
+
+const storeToIndexedDB = (info: CategoryInfo) => {
+ if (info.id !== "root") {
+ void IndexedDB.storeCategoryInfo(info);
+
+ // TODO: Bulk Upsert
+ new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
+ void IndexedDB.storeFileInfo(file);
+ });
+ }
+
+ // TODO: Bulk Upsert
+ info.subCategories.forEach((category) => {
+ void IndexedDB.storeCategoryInfo(category);
+ });
+
+ void IndexedDB.deleteDanglingCategoryInfos(
+ info.id,
+ new Set(info.subCategories.map(({ id }) => id)),
+ );
+
+ return { ...info, exists: true as const };
+};
+
+export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => {
+ return cache.get(id, masterKey);
+};
diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts
new file mode 100644
index 0000000..4144a68
--- /dev/null
+++ b/src/lib/modules/filesystem/directory.ts
@@ -0,0 +1,102 @@
+import * as IndexedDB from "$lib/indexedDB";
+import { trpc, isTRPCClientError } from "$trpc/client";
+import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
+import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
+
+const cache = new FilesystemCache({
+ async fetchFromIndexedDB(id) {
+ const [directory, subDirectories, files] = await Promise.all([
+ id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
+ IndexedDB.getDirectoryInfos(id),
+ IndexedDB.getFileInfos(id),
+ ]);
+
+ if (id === "root") {
+ return {
+ id,
+ exists: true,
+ subDirectories,
+ files,
+ };
+ } else if (directory) {
+ return {
+ id,
+ exists: true,
+ parentId: directory.parentId,
+ name: directory.name,
+ subDirectories,
+ files,
+ };
+ }
+ },
+
+ async fetchFromServer(id, _cachedInfo, masterKey) {
+ try {
+ const directory = await trpc().directory.get.query({ id });
+ const [subDirectories, files, metadata] = await Promise.all([
+ Promise.all(
+ directory.subDirectories.map(async (directory) => ({
+ id: directory.id,
+ parentId: id,
+ ...(await decryptDirectoryMetadata(directory, masterKey)),
+ })),
+ ),
+ Promise.all(
+ directory.files.map(async (file) => ({
+ id: file.id,
+ parentId: id,
+ contentType: file.contentType,
+ ...(await decryptFileMetadata(file, masterKey)),
+ })),
+ ),
+ directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey),
+ ]);
+
+ return storeToIndexedDB(
+ id !== "root"
+ ? {
+ id,
+ parentId: directory.metadata!.parent,
+ subDirectories,
+ files,
+ ...metadata!,
+ }
+ : { id, subDirectories, files },
+ );
+ } catch (e) {
+ if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
+ await IndexedDB.deleteDirectoryInfo(id as number);
+ return { id, exists: false as const };
+ }
+ throw e;
+ }
+ },
+});
+
+const storeToIndexedDB = (info: DirectoryInfo) => {
+ if (info.id !== "root") {
+ void IndexedDB.storeDirectoryInfo(info);
+ }
+
+ // TODO: Bulk Upsert
+ info.subDirectories.forEach((subDirectory) => {
+ void IndexedDB.storeDirectoryInfo(subDirectory);
+ });
+
+ // TODO: Bulk Upsert
+ info.files.forEach((file) => {
+ void IndexedDB.storeFileInfo(file);
+ });
+
+ void IndexedDB.deleteDanglingDirectoryInfos(
+ info.id,
+ new Set(info.subDirectories.map(({ id }) => id)),
+ );
+ void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
+
+ return { ...info, exists: true as const };
+};
+
+export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
+ return cache.get(id, masterKey);
+};
diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts
new file mode 100644
index 0000000..7d5feb9
--- /dev/null
+++ b/src/lib/modules/filesystem/file.ts
@@ -0,0 +1,177 @@
+import * as IndexedDB from "$lib/indexedDB";
+import { trpc, isTRPCClientError } from "$trpc/client";
+import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
+import type { FileInfo, MaybeFileInfo } from "./types";
+
+const cache = new FilesystemCache({
+ async fetchFromIndexedDB(id) {
+ const file = await IndexedDB.getFileInfo(id);
+ const categories = file?.categoryIds
+ ? await Promise.all(
+ file.categoryIds.map(async (categoryId) => {
+ const category = await IndexedDB.getCategoryInfo(categoryId);
+ return category
+ ? { id: category.id, parentId: category.parentId, name: category.name }
+ : undefined;
+ }),
+ )
+ : undefined;
+
+ if (file) {
+ return {
+ id,
+ exists: true,
+ parentId: file.parentId,
+ contentType: file.contentType,
+ name: file.name,
+ createdAt: file.createdAt,
+ lastModifiedAt: file.lastModifiedAt,
+ categories: categories?.filter((category) => !!category) ?? [],
+ };
+ }
+ },
+
+ async fetchFromServer(id, _cachedInfo, masterKey) {
+ try {
+ const file = await trpc().file.get.query({ id });
+ const [categories, metadata] = await Promise.all([
+ Promise.all(
+ file.categories.map(async (category) => ({
+ id: category.id,
+ parentId: category.parent,
+ ...(await decryptCategoryMetadata(category, masterKey)),
+ })),
+ ),
+ decryptFileMetadata(file, masterKey),
+ ]);
+
+ return storeToIndexedDB({
+ id,
+ parentId: file.parent,
+ dataKey: metadata.dataKey,
+ contentType: file.contentType,
+ contentIv: file.contentIv,
+ name: metadata.name,
+ createdAt: metadata.createdAt,
+ lastModifiedAt: metadata.lastModifiedAt,
+ categories,
+ });
+ } catch (e) {
+ if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
+ await IndexedDB.deleteFileInfo(id);
+ return { id, exists: false as const };
+ }
+ throw e;
+ }
+ },
+
+ async bulkFetchFromIndexedDB(ids) {
+ const files = await IndexedDB.bulkGetFileInfos([...ids]);
+ const categories = await Promise.all(
+ files.map(async (file) =>
+ file?.categoryIds
+ ? await Promise.all(
+ file.categoryIds.map(async (categoryId) => {
+ const category = await IndexedDB.getCategoryInfo(categoryId);
+ return category
+ ? { id: category.id, parentId: category.parentId, name: category.name }
+ : undefined;
+ }),
+ )
+ : undefined,
+ ),
+ );
+
+ return new Map(
+ files
+ .filter((file) => !!file)
+ .map((file, index) => [
+ file.id,
+ {
+ ...file,
+ exists: true,
+ categories: categories[index]?.filter((category) => !!category) ?? [],
+ },
+ ]),
+ );
+ },
+
+ async bulkFetchFromServer(ids, masterKey) {
+ const idsArray = [...ids.keys()];
+
+ const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray });
+ const files = await Promise.all(
+ filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
+ const [categories, metadata] = await Promise.all([
+ Promise.all(
+ categoriesRaw.map(async (category) => ({
+ id: category.id,
+ parentId: category.parent,
+ ...(await decryptCategoryMetadata(category, masterKey)),
+ })),
+ ),
+ decryptFileMetadata(metadataRaw, masterKey),
+ ]);
+
+ return {
+ id,
+ exists: true as const,
+ parentId: metadataRaw.parent,
+ contentType: metadataRaw.contentType,
+ contentIv: metadataRaw.contentIv,
+ categories,
+ ...metadata,
+ };
+ }),
+ );
+
+ const existingIds = new Set(filesRaw.map(({ id }) => id));
+ const deletedIds = idsArray.filter((id) => !existingIds.has(id));
+
+ void IndexedDB.bulkDeleteFileInfos(deletedIds);
+ return new Map([
+ ...bulkStoreToIndexedDB(files),
+ ...deletedIds.map((id) => [id, { id, exists: false }] as const),
+ ]);
+ },
+});
+
+const storeToIndexedDB = (info: FileInfo) => {
+ void IndexedDB.storeFileInfo({
+ ...info,
+ categoryIds: info.categories.map(({ id }) => id),
+ });
+
+ info.categories.forEach((category) => {
+ void IndexedDB.storeCategoryInfo(category);
+ });
+
+ return { ...info, exists: true as const };
+};
+
+const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
+ // TODO: Bulk Upsert
+ infos.forEach((info) => {
+ void IndexedDB.storeFileInfo({
+ ...info,
+ categoryIds: info.categories.map(({ id }) => id),
+ });
+ });
+
+ // TODO: Bulk Upsert
+ new Map(
+ infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
+ ).forEach((category) => {
+ void IndexedDB.storeCategoryInfo(category);
+ });
+
+ return infos.map((info) => [info.id, { ...info, exists: true }] as const);
+};
+
+export const getFileInfo = (id: number, masterKey: CryptoKey) => {
+ return cache.get(id, masterKey);
+};
+
+export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => {
+ return cache.bulkGet(new Set(ids), masterKey);
+};
diff --git a/src/lib/modules/filesystem/index.ts b/src/lib/modules/filesystem/index.ts
new file mode 100644
index 0000000..cb9e0f4
--- /dev/null
+++ b/src/lib/modules/filesystem/index.ts
@@ -0,0 +1,4 @@
+export * from "./category";
+export * from "./directory";
+export * from "./file";
+export * from "./types";
diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts
new file mode 100644
index 0000000..7a8c446
--- /dev/null
+++ b/src/lib/modules/filesystem/internal.svelte.ts
@@ -0,0 +1,172 @@
+import { untrack } from "svelte";
+import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
+
+interface FilesystemCacheOptions {
+ fetchFromIndexedDB: (key: K) => Promise;
+ fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise;
+ bulkFetchFromIndexedDB?: (keys: Set) => Promise