diff --git a/package-lock.json b/package-lock.json index 6c702c01..2ec2f3f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,33 +10,35 @@ "dependencies": { "@passcodes/passalgo": "github:PasscodesApp/PassAlgo#main", "@react-native-community/slider": "5.2.0", - "@react-native-vector-icons/fontawesome6": "^13.1.2", + "@react-native-documents/picker": "^12.0.2", + "@react-native-vector-icons/fontawesome6": "^13.1.5", "@shopify/flash-list": "2.0.2", "babel-plugin-inline-import": "^3.0.0", - "drizzle-orm": "^0.45.2", - "expo": "~57.0.26", + "drizzle-orm": "^0.45.4", + "expo": "~57.0.30", "expo-build-properties": "~57.0.22", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.3", - "expo-dev-client": "~57.0.19", + "expo-dev-client": "~57.0.20", "expo-file-system": "~57.0.0", "expo-linear-gradient": "~57.0.2", - "expo-linking": "~57.0.11", + "expo-linking": "~57.0.13", "expo-local-authentication": "~57.0.3", "expo-navigation-bar": "~57.0.3", - "expo-router": "~57.0.24", - "expo-screen-capture": "~57.0.3", - "expo-screen-orientation": "~57.0.2", - "expo-sharing": "~57.0.22", - "expo-splash-screen": "~57.0.9", - "expo-sqlite": "~57.0.3", + "expo-router": "~57.0.28", + "expo-screen-capture": "~57.0.4", + "expo-screen-orientation": "~57.0.3", + "expo-sharing": "~57.0.23", + "expo-splash-screen": "~57.0.10", + "expo-sqlite": "~57.0.4", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", + "papaparse": "^5.7.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-paper": "^5.15.3", - "react-native-quick-crypto": "^1.1.7", + "react-native-quick-crypto": "^1.1.8", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -44,13 +46,14 @@ "react-native-worklets": "0.10.1" }, "devDependencies": { + "@types/papaparse": "^5.5.2", "@types/react": "~19.2.10", "babel-preset-expo": "~57.0.0", - "drizzle-kit": "^0.31.10", + "drizzle-kit": "^0.31.11", "eslint": "^9.39.5", "eslint-config-expo": "~57.0.2", - "expo-atlas": "^0.4.0", - "expo-doctor": "^1.20.1", + "expo-atlas": "^0.5.0", + "expo-doctor": "^1.20.4", "tsx": "^4.23.1", "typescript": "~6.0.3" } @@ -2273,15 +2276,15 @@ } }, "node_modules/@expo/config": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.9.tgz", - "integrity": "sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.10.tgz", + "integrity": "sha512-YpexTqLWEXdbbXgHh7Zhpq+qA5diuvO46lb7TiY81JwqKLAl9zK31APY1D7jUFlDX56owje5RtK0o5bMmGIMUg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.9", + "@expo/config-plugins": "~57.0.10", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -2291,15 +2294,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz", - "integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.10.tgz", + "integrity": "sha512-XFcj01blofSohoOxibrPsrsFPt2gAigrcWE3s6Vg+rSHvP9QVEfnyfmJNVsuIVT0EjY/1rZVJ4SajNDR5cJjpA==", "license": "MIT", "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -2342,9 +2345,9 @@ } }, "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.2.tgz", + "integrity": "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==", "license": "MIT", "dependencies": { "@expo/sudo-prompt": "^9.3.1", @@ -2413,9 +2416,9 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.20.13", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.13.tgz", - "integrity": "sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q==", + "version": "0.20.14", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.14.tgz", + "integrity": "sha512-7sihStGX1ZepJBNbYgeQY7cU2GQ+PSkSROe3g7JOQqnT6OOuX0rXKY0ZNoVnlB2Y/H5FWnnJ6QrPlK3fNg9+bQ==", "license": "MIT", "dependencies": { "@expo/env": "^2.4.3", @@ -2444,9 +2447,9 @@ } }, "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2483,12 +2486,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.11.5", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.5.tgz", - "integrity": "sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==", + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.6.tgz", + "integrity": "sha512-rmMpn7QGp6pUGRCQ3vvB9RP/vqq2q/PpToRh4dL/FbhSZOLZ1YTotpuLHf71FGOEfgKil/4PCd3hkWe/CCeiEg==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -2510,12 +2513,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.7.tgz", - "integrity": "sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.8.tgz", + "integrity": "sha512-5OCHzv/VHVuam9UT6/yeArtUnmkp5VdAH6k4JwdRzK/34ukzdjQK7B9n71fiswZKtm2MmZlPuy4hJo2Ao8CZNw==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.9" + "@expo/config-plugins": "~57.0.10" } }, "node_modules/@expo/json-file": { @@ -2529,12 +2532,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.8.tgz", - "integrity": "sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.9.tgz", + "integrity": "sha512-hox13/K/dqvKG7StLYs9nwyNR/mJ5ieAkNN8jTee6liMPyUQTfpzgticRyWnVxj49Fr8PpTaQab/ArYVEBJOcQ==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.9", + "@expo/config": "~57.0.10", "chalk": "^4.1.2" } }, @@ -2578,19 +2581,19 @@ } }, "node_modules/@expo/metro-config": { - "version": "57.0.12", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.12.tgz", - "integrity": "sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==", + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.13.tgz", + "integrity": "sha512-r+YCwqh8FM5cymkC3MdYO32DqLZQ2wDWkGEnzwgwI40Bj355BvL4XoY0hGgao9vNHjYa7be7XxbVqtMTi76snw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~57.0.9", + "@expo/config": "~57.0.10", "@expo/env": "~2.4.3", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.2", - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", @@ -2617,23 +2620,22 @@ } }, "node_modules/@expo/metro-file-map": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz", - "integrity": "sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.4.tgz", + "integrity": "sha512-gLABIbU3SlxRVVCe56ZfYFbxjhdpAKomQYr6LIeDYf7rca8Pfvzv8FS39hH0+IcdpEHly2Jy0X2nd4/obd8Kig==", "license": "MIT", "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "node_modules/@expo/metro-runtime": { - "version": "57.0.16", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.16.tgz", - "integrity": "sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw==", + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.17.tgz", + "integrity": "sha512-fiWYLaMDV8hoIpyPztRFPhGbHCHWlVfRTac3jZV7tP+fYCpo1QmgsuL23ubnoWqDZhFuRDNk+hQ4y21DMmxEFA==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", @@ -3045,19 +3047,19 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.16", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz", - "integrity": "sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==", + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.19.tgz", + "integrity": "sha512-trBpySOP8Pv/xtMSb5ASAL5y8h6kbZTinakZ5cUbu0lX5iDpiB3Cg3ANYc3coriH7jpl3cJecG4e0PzUMced8g==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.9", - "@expo/config-plugins": "~57.0.9", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", "@expo/config-types": "^57.0.2", - "@expo/image-utils": "^0.11.5", + "@expo/image-utils": "^0.11.6", "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~57.0.13", + "expo-modules-autolinking": "~57.0.16", "resolve-from": "^5.0.0", "semver": "^7.6.0" } @@ -3075,9 +3077,9 @@ } }, "node_modules/@expo/require-utils": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz", - "integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.6.tgz", + "integrity": "sha512-PKWfFdMgwWrlSP6NGplCi2nK0dEM5MeJ2Cj1ijr5VWTmZ+pKsJsJw2dWkBtlFDyxMY8eUjCPZ7hoqFEnbc0Pcw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -3105,19 +3107,6 @@ "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", "license": "MIT" }, - "node_modules/@expo/server": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@expo/server/-/server-0.5.3.tgz", - "integrity": "sha512-WXsWzeBs5v/h0PUfHyNLLz07rwwO5myQ1A5DGYewyyGLmsyl61yVCe8AgAlp1wkiMsqhj2hZqI2u3K10QnCMrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "debug": "^4.3.4", - "source-map-support": "~0.5.21", - "undici": "^6.18.2" - } - }, "node_modules/@expo/spawn-async": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", @@ -3137,9 +3126,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.21", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.21.tgz", - "integrity": "sha512-4s1ypGNxJhJv2r1fj3wZo4Aubao0zGnCajCuiaTp8pTNrT+1vR8KvyoFEwp+c7gpKm2D58nGCiGBXhEnmKHtDQ==", + "version": "57.0.25", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.25.tgz", + "integrity": "sha512-/BbxsKh/LSIFDnWAHRHfJdCcnqHY7WNcWXjld0soKnYXDUbImjvsFK7y5dqq2XZTbOMyf44BpgAWKUR/B8huCA==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -3166,10 +3155,10 @@ } }, "node_modules/@expo/xcpretty": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", - "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==", - "license": "BSD-3-Clause", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.7.tgz", + "integrity": "sha512-EbxYllZ7/ozONVZd425cm4XnolfW+AmSFT5aP7Jdno6oc3WrYQcHz2CO20z/2rwWwYgztmV2PXvYH+BQ8YFjqg==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", @@ -3453,9 +3442,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", - "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3701,6 +3690,19 @@ "integrity": "sha512-484sH8aWEaSjxaZ7HT3YZ8CKDcNes2synko1vdEz5DFEdvKAduxKJTj22L/qBMD7rtIkfbX69DMzWDAGbOAV6w==", "license": "MIT" }, + "node_modules/@react-native-documents/picker": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@react-native-documents/picker/-/picker-12.0.2.tgz", + "integrity": "sha512-tiaPQjSdsaCkKw/N8XB+DXfhOJ+KJ1OYDliBGoBHEqLI7AhUsSPhn8fRI/gHs7hpYzqOBKf+em9MUrPIA9mlWA==", + "license": "MIT", + "funding": { + "url": "https://github.com/react-native-documents/document-picker?sponsor=1" + }, + "peerDependencies": { + "react": "*", + "react-native": ">=0.79.0" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -3711,32 +3713,10 @@ "react-native": ">=0.57" } }, - "node_modules/@react-native-vector-icons/fontawesome6": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/@react-native-vector-icons/fontawesome6/-/fontawesome6-13.1.2.tgz", - "integrity": "sha512-oQvQeDE8kSXm3l+oRKQm/Jo4ewR9YdKW2gFDVVl3st1yY5Nml1ZS4m3lTp3a/KehT9w+Uiv2JNn3kG0VOo+AZw==", - "license": "MIT", - "dependencies": { - "@react-native-vector-icons/common": "^13.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "@expo/config-plugins": ">=10.0.0", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "@expo/config-plugins": { - "optional": true - } - } - }, - "node_modules/@react-native-vector-icons/fontawesome6/node_modules/@react-native-vector-icons/common": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@react-native-vector-icons/common/-/common-13.0.1.tgz", - "integrity": "sha512-UPC6L3tW5rXCjBn4kgw9RPURUILIg8tFpEY2uaYwU8aCjEHkywNCMcAO8+PvMCDkR6aICPeHYA0OXvMgrjsF4g==", + "node_modules/@react-native-vector-icons/common": { + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@react-native-vector-icons/common/-/common-13.0.4.tgz", + "integrity": "sha512-1MvNyRf3KIW747rJsmka/+EdEwV92ccVHedEXFV2kQ0lo6D7uvrIBTtffeDidN4//l8+R4bN3IiQv0tXQzaRXQ==", "license": "MIT", "dependencies": { "find-up": "^8.0.0", @@ -3750,7 +3730,7 @@ "node": ">=20.19.0 <21.0.0 || >=22.0.0" }, "peerDependencies": { - "@react-native-vector-icons/get-image": "^13.0.0", + "@react-native-vector-icons/get-image": "^13.0.2", "@react-native/assets-registry": "*", "expo-font": "*", "react": "*", @@ -3768,7 +3748,7 @@ } } }, - "node_modules/@react-native-vector-icons/fontawesome6/node_modules/find-up": { + "node_modules/@react-native-vector-icons/common/node_modules/find-up": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-8.0.0.tgz", "integrity": "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==", @@ -3784,7 +3764,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@react-native-vector-icons/fontawesome6/node_modules/locate-path": { + "node_modules/@react-native-vector-icons/common/node_modules/locate-path": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-8.0.0.tgz", "integrity": "sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==", @@ -3799,7 +3779,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@react-native-vector-icons/fontawesome6/node_modules/p-limit": { + "node_modules/@react-native-vector-icons/common/node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", @@ -3814,7 +3794,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@react-native-vector-icons/fontawesome6/node_modules/p-locate": { + "node_modules/@react-native-vector-icons/common/node_modules/p-locate": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", @@ -3829,7 +3809,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@react-native-vector-icons/fontawesome6/node_modules/yocto-queue": { + "node_modules/@react-native-vector-icons/common/node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", @@ -3841,6 +3821,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@react-native-vector-icons/fontawesome6": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@react-native-vector-icons/fontawesome6/-/fontawesome6-13.1.5.tgz", + "integrity": "sha512-72z/rpMBIE7NZunmlGeKyntfbPxDkPGtQM9V4XVLptwysmx9Jbnc5STN2zKVtPCRWSifykIuiNNAc/yAomIrBg==", + "license": "MIT", + "dependencies": { + "@react-native-vector-icons/common": "^13.0.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.86.3", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz", @@ -4230,6 +4232,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -5109,13 +5121,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -5393,9 +5398,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "57.0.13", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.13.tgz", - "integrity": "sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ==", + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.16.tgz", + "integrity": "sha512-hGsVNbgxYchVyvBiFAVf+uHVMVIGObiWzAsnv8GsM/ZZRnrhGJLD8x/BLtNNycEYSIb23wPPlSqTGiw0iwwJvQ==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -5444,7 +5449,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^57.0.21", + "expo-widgets": "^57.0.26", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -5527,48 +5532,6 @@ "node": ">=0.6" } }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -6021,52 +5984,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, - "license": "MIT" - }, "node_modules/core-js-compat": { "version": "3.50.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", @@ -6328,9 +6251,9 @@ } }, "node_modules/drizzle-kit": { - "version": "0.31.10", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", - "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", "dev": true, "license": "MIT", "dependencies": { @@ -6344,9 +6267,9 @@ } }, "node_modules/drizzle-orm": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "version": "0.45.4", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.4.tgz", + "integrity": "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", @@ -6355,6 +6278,7 @@ "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", @@ -6397,6 +6321,9 @@ "@neondatabase/serverless": { "optional": true }, + "@netlify/db": { + "optional": true + }, "@op-engineering/op-sqlite": { "optional": true }, @@ -6787,6 +6714,7 @@ "version": "9.39.5", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -7263,31 +7191,31 @@ } }, "node_modules/expo": { - "version": "57.0.26", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.26.tgz", - "integrity": "sha512-U1rsFwcuZNnU61Z/YLnnVC/ZqP0wpxh/rCKUMff3ot/rhLpxFY1zxEqu4vyaexyvBAB1mwpl9lA/ltcHBoQGyg==", + "version": "57.0.30", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.30.tgz", + "integrity": "sha512-dpNWbsqiAHkgInOBClv5PxKCSMAly1n0oBVoIjS/yv8LQtRgDqSkWAnJq16cExndKrgONxdItY+O3J8u6MNYlw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.27", - "@expo/config": "~57.0.9", - "@expo/config-plugins": "~57.0.9", + "@expo/cli": "^57.0.31", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", - "@expo/fingerprint": "^0.20.13", - "@expo/local-build-cache-provider": "^57.0.8", + "@expo/fingerprint": "^0.20.14", + "@expo/local-build-cache-provider": "^57.0.9", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", - "@expo/metro-config": "~57.0.12", + "@expo/metro-config": "~57.0.13", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~57.0.13", - "expo-asset": "~57.0.18", - "expo-constants": "~57.0.20", + "babel-preset-expo": "~57.0.16", + "expo-asset": "~57.0.20", + "expo-constants": "~57.0.22", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-keep-awake": "~57.0.2", - "expo-modules-autolinking": "~57.0.13", - "expo-modules-core": "~57.0.20", + "expo-modules-autolinking": "~57.0.16", + "expo-modules-core": "~57.0.23", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -7325,13 +7253,13 @@ } }, "node_modules/expo-asset": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz", - "integrity": "sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.20.tgz", + "integrity": "sha512-xm1p3Squ/dCzipISO1TE5+kDsIFEUc6DxEtRe2rRgPMDLPi6AumLlrNorFxetiP6zrvxK23xzVlNng/g7o/+aA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.11.5", - "expo-constants": "~57.0.19" + "@expo/image-utils": "^0.11.6", + "expo-constants": "~57.0.22" }, "peerDependencies": { "expo": "*", @@ -7340,23 +7268,17 @@ } }, "node_modules/expo-atlas": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/expo-atlas/-/expo-atlas-0.4.3.tgz", - "integrity": "sha512-nN2bouxFvMsqZqLl0ka+eI9Ofida0PcuoE4v+7fHlgyp95X2cCL8Acf0nRKXmmIBEYazdw0d7BAOZfC0b41oxA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/expo-atlas/-/expo-atlas-0.5.0.tgz", + "integrity": "sha512-TWSPCaMp55rbgmBSoUNkIv8c2MgK2BdILxSvtJRmacMfQ7rFK1zWl7U4HHXG+jm978iZRXBl6ny+QUP0pJlwNg==", "dev": true, "license": "MIT", "dependencies": { - "@expo/server": "^0.5.0", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "compression": "^1.7.4", "connect": "^3.7.0", - "express": "^4.19.2", - "freeport-async": "^2.0.0", - "getenv": "^2.0.0", + "expo-server": "^57.0.3", "morgan": "^1.10.0", "open": "^8.4.2", - "serve-static": "^1.15.0", + "serve-static": "^2.2.1", "stream-json": "^1.8.0" }, "bin": { @@ -7366,6 +7288,80 @@ "expo": "*" } }, + "node_modules/expo-atlas/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/expo-atlas/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/expo-atlas/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/expo-atlas/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/expo-build-properties": { "version": "57.0.22", "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz", @@ -7404,9 +7400,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.20", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz", - "integrity": "sha512-WycPVvBgbluSa6yAh6DKnX646ZHrae0gV2ZLum5fioNkR+DiWyulq1+UfS++Jc6yfAh/l55NOirv6mt5cnQhSw==", + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.22.tgz", + "integrity": "sha512-+mBjnzMWMKIrQa7BP7LTDRC0W7cI+q12/sZns4HzVQJGzgGoAdRJSTEKiObxIw/3EswKXlESkRob8upiL95Q4A==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.3" @@ -7417,13 +7413,13 @@ } }, "node_modules/expo-dev-client": { - "version": "57.0.19", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz", - "integrity": "sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.20.tgz", + "integrity": "sha512-ko5FzWsUPuG4aiQfmqxQA5yAdmal6SSICB70KJLChQYzWo+XDoTpXRgEpzQSr1Ku8KyKW0lkEa48xbnKSgd1xQ==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~57.0.20", - "expo-dev-menu": "~57.0.18", + "expo-dev-launcher": "~57.0.21", + "expo-dev-menu": "~57.0.20", "expo-dev-menu-interface": "~57.0.0", "expo-manifests": "~57.0.2", "expo-updates-interface": "~57.0.2" @@ -7433,13 +7429,13 @@ } }, "node_modules/expo-dev-launcher": { - "version": "57.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.20.tgz", - "integrity": "sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA==", + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.21.tgz", + "integrity": "sha512-dXa1bhCEtSXn4y34rydBvArmoZtcLK7zY0FN3x1JFPTGh81OXUGZWhyzLCAI9nhI23L/MTy7cGCeNyeJmC1ITw==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^57.0.2", - "expo-dev-menu": "~57.0.18", + "expo-dev-menu": "~57.0.20", "expo-manifests": "~57.0.2" }, "peerDependencies": { @@ -7448,9 +7444,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "57.0.18", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.18.tgz", - "integrity": "sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.20.tgz", + "integrity": "sha512-xpf7Rkj++sBA285GqWVJTtl5j/TychD980GKqWA5lVFKM41+xUNmyPMOfO18heAKvX9i620ZHVoHiBGD+6pCdA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "~57.0.0" @@ -7470,9 +7466,9 @@ } }, "node_modules/expo-doctor": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/expo-doctor/-/expo-doctor-1.20.1.tgz", - "integrity": "sha512-u0QYTm3hTZbuMuNdnvscA1X6Xm31ilQ6uxrf8FfzO0ddpOEBrgVEYp8jXgFOBwrRf6DqJoD2j3eKUTWTqvKVzg==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/expo-doctor/-/expo-doctor-1.20.4.tgz", + "integrity": "sha512-Jm1lIqVdO8br9wiqTg5QhJ1u4zLWAhoue4bXsEuzW543vC3XY+U0cpEOmiLkvJhTTgZgIK+24l4i8VzyZ2VLMg==", "dev": true, "license": "MIT", "bin": { @@ -7545,12 +7541,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.11", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz", - "integrity": "sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q==", + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.13.tgz", + "integrity": "sha512-1cjN7Ely0FZqlZItfPYjl6jjrCFLRevrIE/H5XhA6tgDm9uhTaSyJQXyftgt2FvJZ7liheDft7WMnPB9Uktx+A==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.19", + "expo-constants": "~57.0.22", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7583,12 +7579,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "57.0.13", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.13.tgz", - "integrity": "sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==", + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.16.tgz", + "integrity": "sha512-OJh6rMMIIZ/dfwOk2FufJjjsUSw5Boy1Y5Tl+zbBmua/FcD03eEg097dkYIf3iBHVkQ6q9eR08qT88qxvnelow==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.5", + "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -7598,13 +7594,13 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.20", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.20.tgz", - "integrity": "sha512-vEL3aeih32yuD7sQHB3Y3LKXRT8mMgoVQWFIcHtX811Bl0StnNZUG2vT5je4fe+OWYVfkjb6gNqfMc04moREgA==", + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.23.tgz", + "integrity": "sha512-vZQoSlhqU6me5K4KCl42yXB0x3zqAiG2FAjzHmbuez77CslJJQ+82cmJoHC3tN5X8o/KxRArJejKNQIeciyoXA==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", - "expo-modules-jsi": "~57.1.1", + "expo-modules-jsi": "~57.1.2", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7619,9 +7615,9 @@ } }, "node_modules/expo-modules-jsi": { - "version": "57.1.1", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz", - "integrity": "sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==", + "version": "57.1.2", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.2.tgz", + "integrity": "sha512-DYhBwO0fqcKIUma4jF16zRDP/m6ZbawEZbo4eX3gdekREIFqrrz8HKWXydgNUqOyOQcUkdrkZatxi2I9+V6E8g==", "license": "MIT", "peerDependencies": { "react-native": "*" @@ -7642,15 +7638,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.24", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.24.tgz", - "integrity": "sha512-XpqE4Oh4yrmc+sVfsDQPLErBlus//85ulLRghVZltYcdvFm9yNzErmaKrXuBVMUL6D3tnpDtkIgxESG9hYBd7w==", + "version": "57.0.28", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.28.tgz", + "integrity": "sha512-KrW7dGtZ1A9ocpt/Gq1RKczfoAkJ+2LrxweVcEq/FZcJ+eFsaKC9f60ER3uzWJ9n9mb+dbeSPewYOMzG6KMK2Q==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", - "@expo/metro-runtime": "^57.0.16", + "@expo/metro-runtime": "^57.0.17", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.21", + "@expo/ui": "^57.0.25", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -7677,11 +7673,11 @@ }, "peerDependencies": { "@expo/log-box": "^57.0.4", - "@expo/metro-runtime": "^57.0.16", + "@expo/metro-runtime": "^57.0.17", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.20", - "expo-linking": "^57.0.11", + "expo-constants": "^57.0.22", + "expo-linking": "^57.0.13", "react": "*", "react-dom": "*", "react-native": "*", @@ -7714,9 +7710,9 @@ } }, "node_modules/expo-screen-capture": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-screen-capture/-/expo-screen-capture-57.0.3.tgz", - "integrity": "sha512-EpTd6tBgX2nWKJUvYDIrnsC2xWGJnlOLqN48EzLwPTQlbbwDUeWwt+4RDZOyFkWCa67XiUbrzIEOJApNBVZSow==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-screen-capture/-/expo-screen-capture-57.0.4.tgz", + "integrity": "sha512-08+bHhNnEKABCNB687LMYQPhVL200exgCqNdHnEkAo0UyS+SN8k+lh9w9bJ+tAmYpGa76o+hRYMqwpv7oBPEOA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7724,9 +7720,9 @@ } }, "node_modules/expo-screen-orientation": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-57.0.2.tgz", - "integrity": "sha512-9wIa/dU561fidHYChkg6LIDAcYr8uMBa8fOws1Km9/slaF3N/a2kgcy0TUZWvNIe7at3r4g2kVva9vRBJBkaEQ==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-57.0.3.tgz", + "integrity": "sha512-hKtTKAoinahalc5nzMmGfVtj1NfVj4pFJycJGy/HUzrBlu+1rOnHdGUKgaSKkMhr5Mr9GxAxcuLvuM+oyFNFLA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7743,12 +7739,12 @@ } }, "node_modules/expo-sharing": { - "version": "57.0.22", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.22.tgz", - "integrity": "sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg==", + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.23.tgz", + "integrity": "sha512-fN+jrqoWwOpIo+X4utiu/ayCWym23DMgvrTJUHoopkBeO6rEa79ZY4f6ApOlRxvEx2NgQgJEQX+cbPJrjOSRog==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "^57.0.9", + "@expo/config-plugins": "^57.0.10", "@expo/config-types": "^57.0.2", "@expo/plist": "^0.8.1" }, @@ -7759,13 +7755,13 @@ } }, "node_modules/expo-splash-screen": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz", - "integrity": "sha512-7cKDn7uBwg0RkcmY015/5j9st4msWJ4zQiPmQdVZoppW3cFSbv05n7xhM6CjUU466Tx4VRTqDF21HuIHkmnEtA==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.10.tgz", + "integrity": "sha512-W7++tMiyVMviWPTvaTXKpcOwwu5SbriwA2n0ewEhC+ic1LFaUNTUXiIXr95MVMSHaLRgtIG9dGyCTCb+NkekkA==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.9", - "@expo/image-utils": "^0.11.5", + "@expo/config-plugins": "~57.0.10", + "@expo/image-utils": "^0.11.6", "xml2js": "0.6.0" }, "peerDependencies": { @@ -7773,9 +7769,9 @@ } }, "node_modules/expo-sqlite": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.3.tgz", - "integrity": "sha512-gbu9Nwm76DNpSK+MEhCjjjF3/EMyFE96IEDSlfO9jPiF4q48a7bef5EXnWVrwW8rvqCZKYAfSGqSpYNhhhpgIg==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.4.tgz", + "integrity": "sha512-3N/bnDx4+kWEC9GABxKPLe/6RHTZJR/7DNT5tz/cYi8f7emign3ooavXnJn0+JPBA0JhHmsdDPrmC+CtT86Jzg==", "license": "MIT", "dependencies": { "await-lock": "^2.2.2" @@ -7843,29 +7839,29 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.27", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz", - "integrity": "sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg==", + "version": "57.0.31", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.31.tgz", + "integrity": "sha512-NPTMPGQ9i3KzULyP1bzp8hMggoe3QlkfICw388zJuj0UhqfGHEZURkJFuHBksb/ndTjfrCrCBOO0HQz/9QzXIw==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~57.0.9", - "@expo/config-plugins": "~57.0.9", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.3", - "@expo/image-utils": "^0.11.5", - "@expo/inline-modules": "^0.1.7", + "@expo/image-utils": "^0.11.6", + "@expo/inline-modules": "^0.1.8", "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", - "@expo/metro-config": "~57.0.12", - "@expo/metro-file-map": "^57.0.3", + "@expo/metro-config": "~57.0.13", + "@expo/metro-file-map": "^57.0.4", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", - "@expo/prebuild-config": "^57.0.16", - "@expo/require-utils": "^57.0.5", - "@expo/router-server": "^57.0.11", + "@expo/prebuild-config": "^57.0.19", + "@expo/require-utils": "^57.0.6", + "@expo/router-server": "^57.0.13", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", @@ -7926,17 +7922,17 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "57.0.11", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.11.tgz", - "integrity": "sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q==", + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.13.tgz", + "integrity": "sha512-A25869lSp6916GiYjz1y1S/Kfi5qn7S1xepycFv8x8VOrulLBbv0byXl6o3BSLh5nLBx5RiMTjBFM2u+K7JsrA==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.16", + "@expo/metro-runtime": "^57.0.17", "expo": "*", - "expo-constants": "^57.0.19", + "expo-constants": "^57.0.22", "expo-font": "^57.0.4", "expo-router": "*", "expo-server": "^57.0.3", @@ -7960,9 +7956,9 @@ } }, "node_modules/expo/node_modules/@expo/ws-tunnel": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", - "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz", + "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==", "license": "MIT", "peerDependencies": { "ws": "^8.0.0" @@ -8040,89 +8036,6 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8379,26 +8292,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/freeport-async": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", - "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -8875,19 +8768,6 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9008,16 +8888,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -10233,48 +10103,18 @@ "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", @@ -10782,9 +10622,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -11290,6 +11130,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11380,13 +11226,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11447,9 +11286,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "funding": [ { "type": "opencollective", @@ -11466,9 +11305,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11590,20 +11429,6 @@ "dev": true, "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11614,23 +11439,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -11667,22 +11475,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -11908,9 +11700,9 @@ } }, "node_modules/react-native-quick-crypto": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-1.1.7.tgz", - "integrity": "sha512-4On/q3uENJNk6uCyxtigRQzAxPS/h75nDtljX2MBdGwELkz2p0O/eNFSsYIV8l2vYXxyyva6jXQHBCxAcryZ9A==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-1.1.8.tgz", + "integrity": "sha512-KJ96J3XsRtw3RpFgi8nJTBWhIuB5/g87XOMlyHMGHsOjLsE5C6FLEfBTJOGaab/i0EGC0NMrUK63+SR5wsj+Lw==", "license": "MIT", "dependencies": { "@craftzdog/react-native-buffer": "^6.1.2", @@ -12442,13 +12234,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/sandbox-cli-detector": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz", @@ -12789,9 +12574,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -13791,20 +13576,6 @@ "node": ">=8" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -13942,16 +13713,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -14212,24 +13973,45 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/vaul/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "license": "MIT" + }, + "node_modules/vaul/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vaul/node_modules/@radix-ui/react-dialog": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", - "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -14250,15 +14032,15 @@ } }, "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", - "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { @@ -14277,14 +14059,14 @@ } }, "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", - "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4" + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -14302,12 +14084,35 @@ } }, "node_modules/vaul/node_modules/@radix-ui/react-portal": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", - "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -14325,6 +14130,85 @@ } } }, + "node_modules/vaul/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", diff --git a/package.json b/package.json index c4ff0d25..dba29879 100644 --- a/package.json +++ b/package.json @@ -21,33 +21,35 @@ "dependencies": { "@passcodes/passalgo": "github:PasscodesApp/PassAlgo#main", "@react-native-community/slider": "5.2.0", - "@react-native-vector-icons/fontawesome6": "^13.1.2", + "@react-native-documents/picker": "^12.0.2", + "@react-native-vector-icons/fontawesome6": "^13.1.5", "@shopify/flash-list": "2.0.2", "babel-plugin-inline-import": "^3.0.0", - "drizzle-orm": "^0.45.2", - "expo": "~57.0.26", + "drizzle-orm": "^0.45.4", + "expo": "~57.0.30", "expo-build-properties": "~57.0.22", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.3", - "expo-dev-client": "~57.0.19", + "expo-dev-client": "~57.0.20", "expo-file-system": "~57.0.0", "expo-linear-gradient": "~57.0.2", - "expo-linking": "~57.0.11", + "expo-linking": "~57.0.13", "expo-local-authentication": "~57.0.3", "expo-navigation-bar": "~57.0.3", - "expo-router": "~57.0.24", - "expo-screen-capture": "~57.0.3", - "expo-screen-orientation": "~57.0.2", - "expo-sharing": "~57.0.22", - "expo-splash-screen": "~57.0.9", - "expo-sqlite": "~57.0.3", + "expo-router": "~57.0.28", + "expo-screen-capture": "~57.0.4", + "expo-screen-orientation": "~57.0.3", + "expo-sharing": "~57.0.23", + "expo-splash-screen": "~57.0.10", + "expo-sqlite": "~57.0.4", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", + "papaparse": "^5.7.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-paper": "^5.15.3", - "react-native-quick-crypto": "^1.1.7", + "react-native-quick-crypto": "^1.1.8", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -55,13 +57,14 @@ "react-native-worklets": "0.10.1" }, "devDependencies": { + "@types/papaparse": "^5.5.2", "@types/react": "~19.2.10", "babel-preset-expo": "~57.0.0", - "drizzle-kit": "^0.31.10", + "drizzle-kit": "^0.31.11", "eslint": "^9.39.5", "eslint-config-expo": "~57.0.2", - "expo-atlas": "^0.4.0", - "expo-doctor": "^1.20.1", + "expo-atlas": "^0.5.0", + "expo-doctor": "^1.20.4", "tsx": "^4.23.1", "typescript": "~6.0.3" }, diff --git a/src/app/(tabs)/(settings)/_layout.tsx b/src/app/(tabs)/(settings)/_layout.tsx index 6ed04ba3..dd8d6cd7 100644 --- a/src/app/(tabs)/(settings)/_layout.tsx +++ b/src/app/(tabs)/(settings)/_layout.tsx @@ -4,6 +4,21 @@ export default function SettingsLayout() { return ( + + + + + + ); } diff --git a/src/app/(tabs)/(settings)/export-passwords.tsx b/src/app/(tabs)/(settings)/export-passwords.tsx new file mode 100644 index 00000000..8b52c274 --- /dev/null +++ b/src/app/(tabs)/(settings)/export-passwords.tsx @@ -0,0 +1,10 @@ +import { ExportScreenContent } from "@/features/import-export"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export default function ExportPasswordsScreen() { + return ( + + + + ); +} diff --git a/src/app/(tabs)/(settings)/import-passwords.tsx b/src/app/(tabs)/(settings)/import-passwords.tsx new file mode 100644 index 00000000..e124bc61 --- /dev/null +++ b/src/app/(tabs)/(settings)/import-passwords.tsx @@ -0,0 +1,10 @@ +import { ImportScreenContent } from "@/features/import-export"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export default function ImportPasswordsScreen() { + return ( + + + + ); +} diff --git a/src/app/(tabs)/(settings)/import-review.tsx b/src/app/(tabs)/(settings)/import-review.tsx new file mode 100644 index 00000000..60bcc124 --- /dev/null +++ b/src/app/(tabs)/(settings)/import-review.tsx @@ -0,0 +1,10 @@ +import { ImportReviewScreenContent } from "@/features/import-export"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export default function ImportReviewScreen() { + return ( + + + + ); +} diff --git a/src/app/(tabs)/(settings)/settings.tsx b/src/app/(tabs)/(settings)/settings.tsx index a1b5c576..37080b7e 100644 --- a/src/app/(tabs)/(settings)/settings.tsx +++ b/src/app/(tabs)/(settings)/settings.tsx @@ -5,17 +5,7 @@ import Config from "@/config"; import { isBiometricsAuthEnabled, toggleBiometricsFeature, - unlockWithBiometricsApp, } from "@/libs/biometric"; -import { - getGooglePasswordsCSVContent, - type PasswordCSVFormat, - sharePasswordAsCSV, -} from "@/libs/exporting"; -import { - convertRawCSVToPasswords, - getCSVPasswordString, -} from "@/libs/importing"; import { setHomeLayout, useHomeLayout, @@ -27,8 +17,9 @@ import { } from "@/libs/screenshot_prevention"; import { FontAwesome6 } from "@react-native-vector-icons/fontawesome6"; import { LinearGradient } from "expo-linear-gradient"; +import { router } from "expo-router"; import { useState } from "react"; -import { Alert, ScrollView } from "react-native"; +import { ScrollView } from "react-native"; import { Card, Divider, @@ -39,7 +30,6 @@ import { } from "react-native-paper"; import { SafeAreaView } from "react-native-safe-area-context"; -import { usePasswordRepository } from "@/contexts/RepositoryContext"; import PasscodesAutofillServiceModule from "../../../../modules/passcodes-autofill-service/src/PasscodesAutofillServiceModule"; export default function SettingsScreen() { @@ -61,21 +51,6 @@ export default function SettingsScreen() { const homeLayout = useHomeLayout(); const theme = useTheme(); - const passwordRepository = usePasswordRepository(); - - async function handleImportPasswords() { - let content = await getCSVPasswordString(); - let importPasswordList: PasswordCSVFormat[] = - convertRawCSVToPasswords(content); - await passwordRepository.importAll(importPasswordList); - } - - async function handleExportPasswords() { - const result: PasswordCSVFormat[] = await passwordRepository.getAll(); - - let content = await getGooglePasswordsCSVContent(result); - sharePasswordAsCSV(content); - } return ( @@ -183,7 +158,7 @@ export default function SettingsScreen() { ( )} - onPress={handleImportPasswords} + onPress={() => router.navigate("/import-passwords")} /> ( )} - onPress={async () => { - const result = await unlockWithBiometricsApp(); - - if (result) { - handleExportPasswords(); - } else { - Alert.alert( - "Authentication Failed", - "Exporting passwords is protected by App Lock.", - ); - } - }} + onPress={() => router.navigate("/export-passwords")} /> diff --git a/src/config.ts b/src/config.ts index d1c68ab8..5dad7bcf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,12 +5,12 @@ const config = { // Main Config // ---------------------- APP_NAME: "Passcodes", - VERSION: "v4.0.0 - RC.2", + VERSION: "v4.0.0 - RC.3", // ---------------------- // Build Config // ---------------------- - BUILD_VERSION: "v4.0.0-RC.2", // same as VERSION, just without spaces. + BUILD_VERSION: "v4.0.0-RC.3", // same as VERSION, just without spaces. BUILD_ANDROID_VERSIONCODE: 10, BUILD_IOS_VERSIONCODE: "1.0.0", diff --git a/src/db/provider.tsx b/src/db/provider.tsx index e423a6cd..e49d8a4f 100644 --- a/src/db/provider.tsx +++ b/src/db/provider.tsx @@ -181,8 +181,8 @@ function DrizzleDatabaseError({ error }: { error: unknown }) { textAlign: "center", }} > - Passcodes couldn't prepare its local database. Please close and reopen - the app. + Passcodes couldn't prepare its local database. Please close and + reopen the app. diff --git a/src/features/import-export/components/ConflictGroupCard.tsx b/src/features/import-export/components/ConflictGroupCard.tsx new file mode 100644 index 00000000..ef5c0353 --- /dev/null +++ b/src/features/import-export/components/ConflictGroupCard.tsx @@ -0,0 +1,194 @@ +import Text from "@/components/Text"; +import { FontAwesome6 } from "@react-native-vector-icons/fontawesome6"; +import { useState } from "react"; +import { View } from "react-native"; +import { + Card, + Divider, + IconButton, + Switch, + useTheme, +} from "react-native-paper"; + +import type { + CandidatePassword, + ConflictGroup, + ExistingPassword, +} from "../types"; + +const MASK = "••••••••"; + +interface VersionRowProps { + title: string; + password: string; + revealed: boolean; + detail: string; + switchLabel: string; + value: boolean; + onValueChange: () => void; + disabled: boolean; + destructive?: boolean; +} + +function VersionRow({ + title, + password, + revealed, + detail, + switchLabel, + value, + onValueChange, + disabled, + destructive = false, +}: VersionRowProps) { + const theme = useTheme(); + + return ( + + + + {title} + + + {revealed ? password : MASK} + + + {detail} + + + + + + + {switchLabel} + + + + ); +} + +interface ConflictGroupCardProps { + group: ConflictGroup; + /** All incoming passwords; `group.incomingIndexes` point into it. */ + passwords: readonly CandidatePassword[]; + existingById: ReadonlyMap; + importIndexes: ReadonlySet; + removeIds: ReadonlySet; + onToggleImport: (index: number) => void; + onToggleRemove: (id: number) => void; + disabled: boolean; +} + +/** + * One account (same domain and username) that exists in more than one + * version. Passwords stay masked until the user reveals them. + */ +export default function ConflictGroupCard({ + group, + passwords, + existingById, + importIndexes, + removeIds, + onToggleImport, + onToggleRemove, + disabled, +}: ConflictGroupCardProps) { + const theme = useTheme(); + const [revealed, setRevealed] = useState(false); + + const existingRows = group.existingIds.flatMap((id) => { + const row = existingById.get(id); + return row ? [row] : []; + }); + + return ( + + + + + + {group.domain} + + + {group.username} + + + + ( + + )} + onPress={() => setRevealed((value) => !value)} + accessibilityLabel={revealed ? "Hide passwords" : "Show passwords"} + /> + + + {existingRows.map((row) => ( + + + onToggleRemove(row.id)} + disabled={disabled} + destructive + /> + + ))} + + {group.incomingIndexes.map((index) => { + const candidate = passwords[index]; + + if (!candidate) { + return null; + } + + return ( + + + onToggleImport(index)} + disabled={disabled} + /> + + ); + })} + + + ); +} diff --git a/src/features/import-export/components/FormatSelector.tsx b/src/features/import-export/components/FormatSelector.tsx new file mode 100644 index 00000000..0cf79181 --- /dev/null +++ b/src/features/import-export/components/FormatSelector.tsx @@ -0,0 +1,46 @@ +import Text from "@/components/Text"; +import { View } from "react-native"; +import { SegmentedButtons, useTheme } from "react-native-paper"; + +import { TRANSFER_FORMAT_OPTIONS, type TransferFormat } from "../types"; + +interface FormatSelectorProps { + value: TransferFormat; + onChange: (format: TransferFormat) => void; + disabled?: boolean; +} + +export default function FormatSelector({ + value, + onChange, + disabled = false, +}: FormatSelectorProps) { + const theme = useTheme(); + const selected = TRANSFER_FORMAT_OPTIONS.find( + (option) => option.value === value, + ); + + return ( + + onChange(next as TransferFormat)} + buttons={TRANSFER_FORMAT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + disabled, + accessibilityLabel: option.label, + }))} + /> + + {selected ? ( + + {selected.description} + + ) : null} + + ); +} diff --git a/src/features/import-export/components/ImportSummary.tsx b/src/features/import-export/components/ImportSummary.tsx new file mode 100644 index 00000000..66eb94ea --- /dev/null +++ b/src/features/import-export/components/ImportSummary.tsx @@ -0,0 +1,101 @@ +import Text from "@/components/Text"; +import { View } from "react-native"; +import { Card, useTheme } from "react-native-paper"; + +import type { ImportOutcome } from "../types"; + +interface ImportSummaryProps { + /** A cancelled picker shows nothing, so it is not accepted here. */ + outcome: Exclude; +} + +function Row({ label, value }: { label: string; value: number }) { + return ( + + {label} + {value} + + ); +} + +export default function ImportSummary({ outcome }: ImportSummaryProps) { + const theme = useTheme(); + + if (outcome.status === "failed") { + return ( + + + + Import failed + + {outcome.message} + + + ); + } + + if (outcome.status === "empty") { + return ( + + + Nothing to import + + No passwords were found in that file. + + + + ); + } + + const muted = { color: theme.colors.onSurfaceVariant }; + + return ( + + + Import finished + + + + + + + {outcome.categoriesCreated > 0 ? ( + + ) : null} + {outcome.removed > 0 ? ( + + ) : null} + + + {outcome.skipped > 0 ? ( + + Skipped passwords were already on this device or were left out + during the review. + + ) : null} + + {outcome.failureSummary.length > 0 ? ( + + Why some records failed + {outcome.failureSummary.map((line) => ( + + {`${line.message}: ${line.count}`} + + ))} + + ) : null} + + {outcome.warnings.length > 0 ? ( + + Notes + {outcome.warnings.map((warning) => ( + + {warning} + + ))} + + ) : null} + + + ); +} diff --git a/src/features/import-export/files.ts b/src/features/import-export/files.ts new file mode 100644 index 00000000..ab2ccdc3 --- /dev/null +++ b/src/features/import-export/files.ts @@ -0,0 +1,151 @@ +/** + * Native file operations, using `@react-native-documents/picker`: + * - `pick()` + `keepLocalCopy()` to read a file the user selected (works with + * Android `content://` URIs, no storage permission needed), + * - `saveDocuments()` for the system Save As dialog. + * + * This package contains native code: it does not run in Expo Go and needs a + * development build / native rebuild. + * + * Never log file contents or URIs here. + */ + +import { + errorCodes, + isErrorWithCode, + keepLocalCopy, + pick, + saveDocuments, + types, + type DocumentPickerResponse, +} from "@react-native-documents/picker"; +import { File, Paths } from "expo-file-system"; + +import type { SaveRequest } from "./services/exportService"; +import type { TransferFormat } from "./types"; + +function isCancellation(error: unknown): boolean { + return isErrorWithCode(error) && error.code === errorCodes.OPERATION_CANCELED; +} + +/** + * Types offered in the picker. They are deliberately lenient: file managers + * and cloud apps often label CSV/JSON files as plain text, Excel or a generic + * binary type, and a strict filter would hide them. The parsers validate the + * content anyway. + */ +function pickerTypes(format: TransferFormat) { + return format === "google-csv" + ? [types.csv, types.plainText, "application/vnd.ms-excel"] + : [types.json, types.plainText, "application/octet-stream"]; +} + +function safeFileName(name: string | null): string { + const cleaned = (name ?? "").replace(/[^\w.\- ]/g, "_").trim(); + return cleaned === "" ? "import-file" : cleaned; +} + +/** + * Opens the file picker and returns the file's text, or `null` if the user + * cancelled. Throws if the file can't be read. + * + * The picked file is copied into the app cache, read, and the copy is deleted + * again so no credentials are left behind. + */ +export async function pickImportText( + format: TransferFormat, +): Promise { + let picked: DocumentPickerResponse; + + try { + const results = await pick({ + type: pickerTypes(format), + allowMultiSelection: false, + }); + + picked = results[0]; + } catch (error) { + if (isCancellation(error)) { + return null; + } + + throw error; + } + + if (picked.error) { + throw new Error("The file's details couldn't be read."); + } + + const [copy] = await keepLocalCopy({ + files: [{ uri: picked.uri, fileName: safeFileName(picked.name) }], + destination: "cachesDirectory", + }); + + if (copy.status !== "success") { + throw new Error("The file couldn't be copied for reading."); + } + + const localUri = copy.localUri.startsWith("file://") + ? copy.localUri + : `file://${copy.localUri}`; + const localFile = new File(localUri); + + try { + return await localFile.text(); + } finally { + try { + localFile.delete(); + } catch { + // Best effort: it is in the cache directory anyway. + } + } +} + +/** + * Writes the content to a temporary cache file, opens the Save As dialog and + * always deletes the temporary file afterwards. + * + * Resolves `"saved"` only after the system reports the file as written, + * `"cancelled"` if the user closed the dialog, and throws otherwise. + */ +export async function saveTextFile({ + fileName, + mimeType, + content, +}: SaveRequest): Promise<"saved" | "cancelled"> { + const temporary = new File(Paths.cache, fileName); + + if (temporary.exists) { + temporary.delete(); + } + + temporary.write(content); + + try { + const [response] = await saveDocuments({ + sourceUris: [temporary.uri], + // iOS: copy instead of move, the temporary file is deleted below. + copy: true, + mimeType, + fileName, + }); + + if (response.error) { + throw new Error("The file couldn't be written."); + } + + return "saved"; + } catch (error) { + if (isCancellation(error)) { + return "cancelled"; + } + + throw error; + } finally { + try { + temporary.delete(); + } catch { + // Best effort. + } + } +} diff --git a/src/features/import-export/formats/googleCsv.ts b/src/features/import-export/formats/googleCsv.ts new file mode 100644 index 00000000..3a738dfd --- /dev/null +++ b/src/features/import-export/formats/googleCsv.ts @@ -0,0 +1,200 @@ +/** + * Google Password Manager CSV (`name,url,username,password,note`). + * + * Pure functions (no React Native / Expo imports) so they run in plain Node. + * + * Field mapping: + * name <-> domain + * url <-> url (the `https://local.` placeholder used + * by Passcodes for "no URL" is written on export + * and turned back into `null` on import) + * username <-> username + * password <-> password + * note <-> notes + * + * The CSV cannot carry categories or timestamps; they are not exported and + * imported rows get the import time and no category. + */ + +import Papa from "papaparse"; + +import type { + CandidatePassword, + FailureReason, + ParseResult, + RecordFailure, +} from "../types"; + +export const GOOGLE_CSV_COLUMNS = [ + "name", + "url", + "username", + "password", + "note", +] as const; + +/** `url` and `note` are optional, a row without them is still a password. */ +const REQUIRED_COLUMNS = ["name", "username", "password"] as const; + +export interface GoogleCsvExportRow { + domain: string; + url: string | null; + username: string; + password: string; + notes: string | null; +} + +/** Stable placeholder Passcodes uses for entries without a real URL. */ +export function placeholderUrl(domain: string): string { + return `https://local.${domain}`; +} + +export function serializeGoogleCsv( + rows: readonly GoogleCsvExportRow[], +): string { + // Papa quotes a field only when needed (comma, quote, newline, edge + // whitespace) and doubles embedded quotes. `escapeFormulae` stays off on + // purpose: it would rewrite passwords that start with `=`, `+`, `-` or `@`. + return Papa.unparse( + { + fields: [...GOOGLE_CSV_COLUMNS], + data: rows.map((row) => [ + row.domain, + row.url || placeholderUrl(row.domain), + row.username, + row.password, + row.notes ?? "", + ]), + }, + { newline: "\r\n" }, + ); +} + +/** "https://www.Example.com/login" -> "example.com", "" when there is no host. */ +function domainFromUrl(url: string): string { + const match = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?([^/?#:]+)/i.exec(url); + const host = match?.[1]; + + return host ? host.toLowerCase().replace(/^www\./, "") : ""; +} + +export function parseGoogleCsv(text: string): ParseResult { + // Papa strips a leading BOM itself, but only for string input it controls. + const content = text.replace(/^/, ""); + + if (content.trim() === "") { + return { ok: false, error: "The file is empty." }; + } + + const headers: string[] = []; + + const result = Papa.parse>(content, { + header: true, + delimiter: ",", + // Credentials must stay strings ("007" must not become 7). + dynamicTyping: false, + // "greedy" also drops lines that contain only separators/whitespace. + skipEmptyLines: "greedy", + transformHeader: (header: string) => { + const normalized = header.trim().toLowerCase(); + headers.push(normalized); + return normalized; + }, + }); + + if (new Set(headers).size !== headers.length) { + return { + ok: false, + error: "The file has duplicate column names, so it can't be read safely.", + }; + } + + const missing = REQUIRED_COLUMNS.filter( + (column) => !headers.includes(column), + ); + + if (missing.length > 0) { + return { + ok: false, + error: `This doesn't look like a Google Password Manager CSV. Missing column(s): ${missing.join(", ")}.`, + }; + } + + const rowErrors = new Map(); + + for (const error of result.errors) { + if (error.code === "MissingQuotes") { + // An unclosed quote swallows the rest of the file into one field. + return { + ok: false, + error: + "The file has a quote that is never closed, so it can't be read reliably.", + }; + } + + if (error.row !== undefined) { + rowErrors.set( + error.row, + error.type === "FieldMismatch" ? "column-mismatch" : "malformed-row", + ); + } + } + + const passwords: CandidatePassword[] = []; + const failures: RecordFailure[] = []; + + result.data.forEach((row, index) => { + const position = index + 1; + const rowError = rowErrors.get(index); + + if (rowError) { + failures.push({ table: "passwords", position, reason: rowError }); + return; + } + + const url = (row["url"] ?? "").trim(); + const domain = (row["name"] ?? "").trim() || domainFromUrl(url); + const username = (row["username"] ?? "").trim(); + const password = row["password"] ?? ""; + const note = row["note"] ?? ""; + + const reason: FailureReason | null = + domain === "" + ? "missing-domain" + : username === "" + ? "missing-username" + : password === "" + ? "missing-password" + : null; + + if (reason) { + failures.push({ table: "passwords", position, reason }); + return; + } + + const isPlaceholder = + url.toLowerCase() === placeholderUrl(domain).toLowerCase(); + + passwords.push({ + domain, + url: url === "" || isPlaceholder ? null : url, + username, + password, + notes: note === "" ? null : note, + categorySourceId: null, + createdAt: null, + updatedAt: null, + }); + }); + + return { + ok: true, + data: { + passwords, + categories: [], + failures, + warnings: [], + detected: passwords.length + failures.length, + }, + }; +} diff --git a/src/features/import-export/formats/passcodesJson.ts b/src/features/import-export/formats/passcodesJson.ts new file mode 100644 index 00000000..95bb57f8 --- /dev/null +++ b/src/features/import-export/formats/passcodesJson.ts @@ -0,0 +1,404 @@ +/** + * Passcodes JSON backup, format version 1. + * + * Pure functions (no React Native / Expo imports) so they run in plain Node. + * + * ```json + * { + * "format": "passcodes", + * "version": 1, + * "exported-at": "2026-10-15T16:00:00.000Z", + * "counts": { "passwords": 1, "categories": 1 }, + * "data": { + * "passwords": [ + * { "_id": 1, "domain": "...", "url": null, "username": "...", + * "password": "...", "notes": null, "category-id": 2, + * "created-at": "2026-01-01 12:00:00", "updated-at": "2026-02-01 12:00:00" } + * ], + * "categories": [ + * { "_id": 2, "name": "...", "created-at": "...", "updated-at": "..." } + * ] + * } + * } + * ``` + * + * Rules: + * - Every table is an array under `data`, every item has an `_id` that is + * unique across the WHOLE file (passwords are numbered 1..n, categories + * continue at n+1). `_id`s are file-local: they are never database ids. + * - References use the target's `_id` (`category-id`), `null` = Unsorted. + * - Timestamps are the raw stored strings, never replaced by the import time. + * - New tables = new keys under `data` plus a `counts` entry. Additive + * changes keep `version`; breaking changes increase it. + * - The importer is lenient about cosmetic problems (bad timestamp, dangling + * category reference: the password is still imported and a warning is + * added) and strict about structure (format, version, counts). + */ + +import type { + CandidateCategory, + CandidatePassword, + FailureReason, + ParseResult, + RecordFailure, +} from "../types"; + +export const PASSCODES_FORMAT = "passcodes"; +export const PASSCODES_JSON_VERSION = 1; + +export interface PasswordExportRow { + id: number; + domain: string; + username: string; + password: string; + notes: string | null; + url: string | null; + createdAt: string | null; + updatedAt: string | null; + categoryId: number | null; +} + +export interface CategoryExportRow { + id: number; + name: string; + createdAt: string | null; + updatedAt: string | null; +} + +export function serializePasscodesJson( + passwords: readonly PasswordExportRow[], + categories: readonly CategoryExportRow[], + exportedAt: Date, +): string { + const fileIdByCategoryId = new Map(); + + categories.forEach((category, index) => { + fileIdByCategoryId.set(category.id, passwords.length + index + 1); + }); + + const document = { + format: PASSCODES_FORMAT, + version: PASSCODES_JSON_VERSION, + "exported-at": exportedAt.toISOString(), + counts: { passwords: passwords.length, categories: categories.length }, + data: { + passwords: passwords.map((row, index) => ({ + _id: index + 1, + domain: row.domain, + url: row.url, + username: row.username, + password: row.password, + notes: row.notes, + "category-id": + row.categoryId === null + ? null + : (fileIdByCategoryId.get(row.categoryId) ?? null), + "created-at": row.createdAt, + "updated-at": row.updatedAt, + })), + categories: categories.map((row, index) => ({ + _id: passwords.length + index + 1, + name: row.name, + "created-at": row.createdAt, + "updated-at": row.updatedAt, + })), + }, + }; + + return JSON.stringify(document, null, 2); +} + +// --------------------------------------------------------------------------- +// Parsing & validation +// --------------------------------------------------------------------------- + +const KNOWN_TABLES = ["passwords", "categories"]; + +/** `YYYY-MM-DD HH:MM:SS` (SQLite) or ISO 8601, optional fraction and zone. */ +const TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +type Read = { ok: true; value: T } | { ok: false; reason: FailureReason }; + +/** Absent / null -> null, a string -> the string, anything else -> invalid. */ +function readOptionalString(raw: unknown): Read { + if (raw === undefined || raw === null) { + return { ok: true, value: null }; + } + + if (typeof raw !== "string") { + return { ok: false, reason: "invalid-field" }; + } + + return { ok: true, value: raw.trim() === "" ? null : raw }; +} + +/** Never fails: an unreadable timestamp becomes `null` and is counted. */ +function readTimestamp(raw: unknown, counter: { bad: number }): string | null { + if (raw === undefined || raw === null) { + return null; + } + + if (typeof raw === "string" && TIMESTAMP_PATTERN.test(raw.trim())) { + return raw.trim(); + } + + counter.bad += 1; + return null; +} + +export function parsePasscodesJson(text: string): ParseResult { + const content = text.replace(/^/, ""); + + if (content.trim() === "") { + return { ok: false, error: "The file is empty." }; + } + + let root: unknown; + + try { + root = JSON.parse(content); + } catch { + return { ok: false, error: "The file isn't valid JSON." }; + } + + const document = asRecord(root); + + if (!document || document["format"] !== PASSCODES_FORMAT) { + return { ok: false, error: "This file isn't a Passcodes backup." }; + } + + const version = document["version"]; + + if ( + typeof version !== "number" || + !Number.isInteger(version) || + version < 1 + ) { + return { ok: false, error: "The backup's version number is invalid." }; + } + + if (version > PASSCODES_JSON_VERSION) { + return { + ok: false, + error: `This backup uses a newer format (version ${version}) than this app understands (version ${PASSCODES_JSON_VERSION}). Update Passcodes and try again.`, + }; + } + + const data = asRecord(document["data"]); + const counts = asRecord(document["counts"]); + + if (!data || !counts) { + return { + ok: false, + error: "The backup is missing its data or item counts.", + }; + } + + const rawPasswords = data["passwords"]; + const rawCategories = data["categories"]; + + if (!Array.isArray(rawPasswords) || !Array.isArray(rawCategories)) { + return { + ok: false, + error: "The backup is missing its passwords or categories list.", + }; + } + + if ( + counts["passwords"] !== rawPasswords.length || + counts["categories"] !== rawCategories.length + ) { + return { + ok: false, + error: + "The backup's item counts don't match its contents. The file may be incomplete or edited.", + }; + } + + const warnings: string[] = []; + const failures: RecordFailure[] = []; + const passwords: CandidatePassword[] = []; + const categories: CandidateCategory[] = []; + const usedIds = new Set(); + const badTimestamps = { bad: 0 }; + + const unknownTables = Object.keys(data).filter( + (key) => !KNOWN_TABLES.includes(key), + ).length; + + if (unknownTables > 0) { + warnings.push( + `This backup contains ${unknownTables} section(s) this version of Passcodes can't restore. They were ignored.`, + ); + } + + function claimId(raw: unknown): Read { + if (!isPositiveInteger(raw)) { + return { ok: false, reason: "invalid-id" }; + } + + if (usedIds.has(raw)) { + return { ok: false, reason: "duplicate-id" }; + } + + usedIds.add(raw); + return { ok: true, value: raw }; + } + + rawPasswords.forEach((raw: unknown, index) => { + const position = index + 1; + const fail = (reason: FailureReason) => + failures.push({ table: "passwords", position, reason }); + + const record = asRecord(raw); + + if (!record) { + fail("invalid-record"); + return; + } + + const domain = record["domain"]; + const username = record["username"]; + const password = record["password"]; + + if ( + typeof domain !== "string" || + typeof username !== "string" || + typeof password !== "string" + ) { + fail("invalid-field"); + return; + } + + if (domain.trim() === "") { + fail("missing-domain"); + return; + } + + if (username.trim() === "") { + fail("missing-username"); + return; + } + + if (password === "") { + fail("missing-password"); + return; + } + + const url = readOptionalString(record["url"]); + const notes = readOptionalString(record["notes"]); + const categoryRaw = record["category-id"]; + + if ( + !url.ok || + !notes.ok || + (categoryRaw !== undefined && + categoryRaw !== null && + !isPositiveInteger(categoryRaw)) + ) { + fail("invalid-field"); + return; + } + + const id = claimId(record["_id"]); + + if (!id.ok) { + fail(id.reason); + return; + } + + passwords.push({ + domain, + url: url.value, + username, + password, + notes: notes.value, + categorySourceId: isPositiveInteger(categoryRaw) ? categoryRaw : null, + createdAt: readTimestamp(record["created-at"], badTimestamps), + updatedAt: readTimestamp(record["updated-at"], badTimestamps), + }); + }); + + rawCategories.forEach((raw: unknown, index) => { + const position = index + 1; + const fail = (reason: FailureReason) => + failures.push({ table: "categories", position, reason }); + + const record = asRecord(raw); + + if (!record) { + fail("invalid-record"); + return; + } + + const name = record["name"]; + + if (typeof name !== "string" || name.trim() === "") { + fail("invalid-field"); + return; + } + + const id = claimId(record["_id"]); + + if (!id.ok) { + fail(id.reason); + return; + } + + categories.push({ + sourceId: id.value, + name, + createdAt: readTimestamp(record["created-at"], badTimestamps), + updatedAt: readTimestamp(record["updated-at"], badTimestamps), + }); + }); + + // A password that points at a category that is not in the file is still a + // valid password: import it as Unsorted instead of losing it. + const knownCategoryIds = new Set(categories.map((c) => c.sourceId)); + let danglingReferences = 0; + + for (const candidate of passwords) { + if ( + candidate.categorySourceId !== null && + !knownCategoryIds.has(candidate.categorySourceId) + ) { + candidate.categorySourceId = null; + danglingReferences += 1; + } + } + + if (danglingReferences > 0) { + warnings.push( + `${danglingReferences} password(s) referred to a category that isn't in the file and will be imported as Unsorted.`, + ); + } + + if (badTimestamps.bad > 0) { + warnings.push( + `${badTimestamps.bad} timestamp(s) couldn't be read; the import time was used instead.`, + ); + } + + return { + ok: true, + data: { + passwords, + categories, + failures, + warnings, + detected: rawPasswords.length, + }, + }; +} diff --git a/src/features/import-export/importSession.ts b/src/features/import-export/importSession.ts new file mode 100644 index 00000000..472430e9 --- /dev/null +++ b/src/features/import-export/importSession.ts @@ -0,0 +1,52 @@ +import { useSyncExternalStore } from "react"; + +import type { ImportOutcome, ImportSession } from "./types"; + +/** + * In-memory hand-off between the Import screen and the Review screen + * (same pattern as `libs/selection_store.ts`). + * + * The session contains plaintext passwords, so it is deliberately NOT put in + * route params or React state, and it is cleared as soon as the review is + * finished or left. + */ +let session: ImportSession | null = null; +let outcome: ImportOutcome | null = null; +const listeners = new Set<() => void>(); + +function emit() { + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function setImportSession(next: ImportSession) { + session = next; + emit(); +} + +export function clearImportSession() { + if (session !== null) { + session = null; + emit(); + } +} + +export function setImportOutcome(next: ImportOutcome | null) { + outcome = next; + emit(); +} + +export function useImportSession(): ImportSession | null { + return useSyncExternalStore(subscribe, () => session); +} + +export function useImportOutcome(): ImportOutcome | null { + return useSyncExternalStore(subscribe, () => outcome); +} diff --git a/src/features/import-export/index.ts b/src/features/import-export/index.ts new file mode 100644 index 00000000..5a483232 --- /dev/null +++ b/src/features/import-export/index.ts @@ -0,0 +1,3 @@ +export { default as ExportScreenContent } from "./screens/ExportScreenContent"; +export { default as ImportReviewScreenContent } from "./screens/ImportReviewScreenContent"; +export { default as ImportScreenContent } from "./screens/ImportScreenContent"; diff --git a/src/features/import-export/screens/ExportScreenContent.tsx b/src/features/import-export/screens/ExportScreenContent.tsx new file mode 100644 index 00000000..689e104c --- /dev/null +++ b/src/features/import-export/screens/ExportScreenContent.tsx @@ -0,0 +1,172 @@ +import Text from "@/components/Text"; +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import { unlockWithBiometricsApp } from "@/libs/biometric"; +import { FontAwesome6 } from "@react-native-vector-icons/fontawesome6"; +import { useFocusEffect } from "expo-router"; +import { useCallback, useRef, useState } from "react"; +import { Alert, ScrollView } from "react-native"; +import { Button, Card, useTheme } from "react-native-paper"; + +import FormatSelector from "../components/FormatSelector"; +import { saveTextFile } from "../files"; +import { exportPasswords } from "../services/exportService"; +import type { ExportOutcome, TransferFormat } from "../types"; + +export default function ExportScreenContent() { + const theme = useTheme(); + const passwordRepository = usePasswordRepository(); + const categoryRepository = useCategoryRepository(); + + const [format, setFormat] = useState("google-csv"); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + + // undefined = loading, null = the count could not be read. + const [count, setCount] = useState(undefined); + + const runningRef = useRef(false); + + // Re-read from the repository whenever the screen is shown, so the number + // is never a stale copy. + const refreshCount = useCallback(() => { + passwordRepository + .count() + .then(setCount) + .catch(() => setCount(null)); + }, [passwordRepository]); + + useFocusEffect(refreshCount); + + async function handleExport() { + if (runningRef.current) { + return; + } + + runningRef.current = true; + setBusy(true); + setOutcome(null); + + try { + const unlocked = await unlockWithBiometricsApp(); + + if (!unlocked) { + Alert.alert( + "Authentication Failed", + "Exporting passwords is protected by App Lock.", + ); + return; + } + + setOutcome( + await exportPasswords( + format, + { passwords: passwordRepository, categories: categoryRepository }, + saveTextFile, + ), + ); + } finally { + runningRef.current = false; + setBusy(false); + refreshCount(); + } + } + + const countLabel = + count === undefined + ? "Counting passwords…" + : count === null + ? "The number of passwords couldn't be read." + : count === 0 + ? "You have no passwords to export yet." + : `${count} ${count === 1 ? "password" : "passwords"} will be exported.`; + + return ( + + + Choose a format. All of your stored passwords are exported + {format === "passcodes-json" ? ", together with your categories" : ""}. + + + + + + + + {countLabel} + + + The exported file is not encrypted. Anyone who gets the file can + read your passwords, so keep it somewhere safe. + + + + + + + {outcome ? : null} + + ); +} + +function ExportStatus({ outcome }: { outcome: ExportOutcome }) { + const theme = useTheme(); + + return ( + + + {outcome.status === "failed" ? ( + <> + + Export failed + + {outcome.message} + + ) : outcome.status === "empty" ? ( + <> + Nothing to export + + You have no passwords to export yet. + + + ) : outcome.status === "cancelled" ? ( + <> + Export cancelled + Nothing was saved. + + ) : ( + <> + Export saved + + {`${outcome.count} ${outcome.count === 1 ? "password was" : "passwords were"} saved to the location you chose.`} + + + )} + + + ); +} diff --git a/src/features/import-export/screens/ImportReviewScreenContent.tsx b/src/features/import-export/screens/ImportReviewScreenContent.tsx new file mode 100644 index 00000000..ccaa5ed7 --- /dev/null +++ b/src/features/import-export/screens/ImportReviewScreenContent.tsx @@ -0,0 +1,217 @@ +import Text from "@/components/Text"; +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import { unlockWithBiometricsApp } from "@/libs/biometric"; +import { router, useNavigation } from "expo-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, FlatList, View } from "react-native"; +import { Button, Card, useTheme } from "react-native-paper"; + +import ConflictGroupCard from "../components/ConflictGroupCard"; +import { + clearImportSession, + setImportOutcome, + useImportSession, +} from "../importSession"; +import { commitImport } from "../services/importService"; +import { defaultDecisions } from "../utils/duplicates"; + +function toggled(set: ReadonlySet, value: T): Set { + const next = new Set(set); + + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + + return next; +} + +/** + * Lets the user decide what to do with accounts that exist in more than one + * version. Default: keep both versions of everything, remove nothing. + */ +export default function ImportReviewScreenContent() { + const theme = useTheme(); + const navigation = useNavigation(); + const passwordRepository = usePasswordRepository(); + const categoryRepository = useCategoryRepository(); + const session = useImportSession(); + + const [importIndexes, setImportIndexes] = useState>(() => + session ? defaultDecisions(session.plan).importIndexes : new Set(), + ); + const [removeIds, setRemoveIds] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const runningRef = useRef(false); + + // The session holds plaintext passwords: drop it whenever this screen is + // left (back button, gesture, or after finishing). + useEffect(() => { + return navigation.addListener("beforeRemove", () => { + clearImportSession(); + }); + }, [navigation]); + + const existingById = useMemo( + () => new Map((session?.existing ?? []).map((row) => [row.id, row])), + [session], + ); + + if (!session) { + return ( + + + There is nothing to review. Start an import first. + + + + ); + } + + const { plan, parsed } = session; + const allConflictIndexes = plan.conflicts.flatMap((g) => g.incomingIndexes); + const importCount = plan.fresh.length + importIndexes.size; + + async function handleConfirm() { + if (!session || runningRef.current) { + return; + } + + runningRef.current = true; + setBusy(true); + + try { + if (removeIds.size > 0) { + const unlocked = await unlockWithBiometricsApp(); + + if (!unlocked) { + Alert.alert( + "Authentication Failed", + "Removing passwords is protected by App Lock.", + ); + return; + } + } + + const outcome = await commitImport( + session, + { importIndexes, removeExistingIds: removeIds }, + { passwords: passwordRepository, categories: categoryRepository }, + ); + + setImportOutcome(outcome); + router.back(); + } finally { + runningRef.current = false; + setBusy(false); + } + } + + function handleCancel() { + setImportOutcome({ status: "cancelled" }); + router.back(); + } + + const muted = { color: theme.colors.onSurfaceVariant }; + + return ( + + group.key} + extraData={[importIndexes, removeIds, busy]} + contentContainerStyle={{ padding: 16, gap: 12 }} + ListHeaderComponent={ + + + + + {`${plan.conflicts.length} ${plan.conflicts.length === 1 ? "account has" : "accounts have"} more than one version`} + + + + These passwords share the same domain and username but have + different passwords. This can happen if a password appears + more than once in the file or differs from one already saved + on your device. By default, both versions are kept so you can + review them later. + + + + {`${plan.fresh.length} other ${plan.fresh.length === 1 ? "password is" : "passwords are"} new and will be imported. ${plan.duplicates} exact ${plan.duplicates === 1 ? "duplicate is" : "duplicates are"} skipped. ${parsed.failures.filter((f) => f.table === "passwords").length} invalid ${parsed.failures.filter((f) => f.table === "passwords").length === 1 ? "record" : "records"} will not be imported.`} + + + + + + + + + + } + renderItem={({ item }) => ( + + setImportIndexes((current) => toggled(current, index)) + } + onToggleRemove={(id) => + setRemoveIds((current) => toggled(current, id)) + } + disabled={busy} + /> + )} + /> + + + + + + + ); +} diff --git a/src/features/import-export/screens/ImportScreenContent.tsx b/src/features/import-export/screens/ImportScreenContent.tsx new file mode 100644 index 00000000..f21cd9e0 --- /dev/null +++ b/src/features/import-export/screens/ImportScreenContent.tsx @@ -0,0 +1,134 @@ +import Text from "@/components/Text"; +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import { FontAwesome6 } from "@react-native-vector-icons/fontawesome6"; +import { router } from "expo-router"; +import { useEffect, useRef, useState } from "react"; +import { ScrollView } from "react-native"; +import { Button, useTheme } from "react-native-paper"; + +import FormatSelector from "../components/FormatSelector"; +import ImportSummary from "../components/ImportSummary"; +import { pickImportText } from "../files"; +import { + setImportOutcome, + setImportSession, + useImportOutcome, +} from "../importSession"; +import { commitImport, prepareImport } from "../services/importService"; +import type { TransferFormat } from "../types"; +import { defaultDecisions } from "../utils/duplicates"; + +export default function ImportScreenContent() { + const theme = useTheme(); + const passwordRepository = usePasswordRepository(); + const categoryRepository = useCategoryRepository(); + + const [format, setFormat] = useState("google-csv"); + const [busy, setBusy] = useState(false); + const outcome = useImportOutcome(); + + // A ref, not state: blocks a second tap before React re-renders. + const runningRef = useRef(false); + + // Start clean, and don't show an old result the next time the screen opens. + useEffect(() => { + setImportOutcome(null); + return () => setImportOutcome(null); + }, []); + + async function handleImport() { + if (runningRef.current) { + return; + } + + runningRef.current = true; + setBusy(true); + setImportOutcome(null); + + const repositories = { + passwords: passwordRepository, + categories: categoryRepository, + }; + + try { + const prepared = await prepareImport( + format, + repositories, + pickImportText, + ); + + if (prepared.status !== "ready") { + setImportOutcome(prepared); + return; + } + + const { session } = prepared; + + if (session.plan.conflicts.length > 0) { + // The user decides; the review screen reports the final result. + setImportSession(session); + router.push("/import-review"); + return; + } + + setImportOutcome( + await commitImport( + session, + defaultDecisions(session.plan), + repositories, + ), + ); + } finally { + runningRef.current = false; + setBusy(false); + } + } + + return ( + + + Choose a format, then select the file to import. Imported passwords are + added to the ones you already have. + + + + Passwords that already exist with the same domain, username and password + are skipped. If the same domain and username has a different password, + you can review those before anything is imported. + + + + + + + {outcome && outcome.status !== "cancelled" ? ( + + ) : null} + + ); +} diff --git a/src/features/import-export/services/exportService.ts b/src/features/import-export/services/exportService.ts new file mode 100644 index 00000000..b00c646f --- /dev/null +++ b/src/features/import-export/services/exportService.ts @@ -0,0 +1,97 @@ +/** + * Export workflow. No React Native / Expo imports: the repositories and the + * Save As function are passed in so it can be tested in plain Node. + * Messages never contain password values. + */ + +import type { CategoryRepository } from "@/repositories/CategoryRepository"; +import type { PasswordRepository } from "@/repositories/PasswordRepository"; + +import { serializeGoogleCsv } from "../formats/googleCsv"; +import { serializePasscodesJson } from "../formats/passcodesJson"; +import type { ExportOutcome, TransferFormat } from "../types"; + +export interface ExportRepositories { + passwords: Pick; + categories: Pick; +} + +export interface SaveRequest { + fileName: string; + mimeType: string; + content: string; +} + +/** Resolves once the user's chosen destination was actually written. */ +export type SaveFile = (request: SaveRequest) => Promise<"saved" | "cancelled">; + +function dateStamp(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Never throws. */ +export async function exportPasswords( + format: TransferFormat, + repositories: ExportRepositories, + save: SaveFile, + now: Date = new Date(), +): Promise { + let passwordRows: Awaited< + ReturnType + >; + + try { + passwordRows = await repositories.passwords.getAll(); + } catch { + return { + status: "failed", + message: "Your passwords couldn't be read. Nothing was exported.", + }; + } + + if (passwordRows.length === 0) { + return { status: "empty" }; + } + + let request: SaveRequest; + + try { + if (format === "google-csv") { + request = { + fileName: `passcodes-export-${dateStamp(now)}.csv`, + mimeType: "text/csv", + content: serializeGoogleCsv(passwordRows), + }; + } else { + const categoryRows = await repositories.categories.getAll(); + + request = { + fileName: `passcodes-backup-${dateStamp(now)}.json`, + mimeType: "application/json", + content: serializePasscodesJson(passwordRows, categoryRows, now), + }; + } + } catch { + return { + status: "failed", + message: "The export file couldn't be created. Nothing was exported.", + }; + } + + try { + const result = await save(request); + + return result === "saved" + ? { + status: "saved", + count: passwordRows.length, + fileName: request.fileName, + } + : { status: "cancelled" }; + } catch { + return { + status: "failed", + message: "The file couldn't be saved. Nothing was exported.", + }; + } +} diff --git a/src/features/import-export/services/importService.ts b/src/features/import-export/services/importService.ts new file mode 100644 index 00000000..39791129 --- /dev/null +++ b/src/features/import-export/services/importService.ts @@ -0,0 +1,300 @@ +/** + * Import workflow, in two steps so the user can review duplicates: + * + * prepareImport() pick -> read -> parse -> compare with the database + * commitImport() write the chosen records, categories first + * + * No React Native / Expo imports: file access and repositories are passed in, + * so the workflow can be tested in plain Node. Messages never contain values + * from the file. + */ + +import type { CategoryRepository } from "@/repositories/CategoryRepository"; +import type { + ImportPasswordRow, + PasswordRepository, +} from "@/repositories/PasswordRepository"; + +import { parseGoogleCsv } from "../formats/googleCsv"; +import { parsePasscodesJson } from "../formats/passcodesJson"; +import { + FAILURE_REASON_TEXT, + type CandidateCategory, + type FailureReason, + type FailureSummaryLine, + type ImportDecisions, + type ImportOutcome, + type ImportSession, + type ParseResult, + type PrepareOutcome, + type TransferFormat, +} from "../types"; +import { + analyzeImport, + removableExistingIds, + selectedIndexes, + skippedByUser, +} from "../utils/duplicates"; + +export interface ImportRepositories { + passwords: Pick; + categories: Pick; +} + +/** Resolves to the file text, or `null` if the user cancelled the picker. */ +export type PickText = (format: TransferFormat) => Promise; + +function parseFile(format: TransferFormat, text: string): ParseResult { + return format === "google-csv" + ? parseGoogleCsv(text) + : parsePasscodesJson(text); +} + +function describeNothingValid(failed: number): string { + return `None of the ${failed} record(s) in the file were valid, so nothing was imported.`; +} + +/** Never throws. Does not change the database. */ +export async function prepareImport( + format: TransferFormat, + repositories: Pick, + pickText: PickText, +): Promise { + let text: string | null; + + try { + text = await pickText(format); + } catch { + return { + status: "failed", + message: "The selected file couldn't be read. Nothing was imported.", + }; + } + + if (text === null) { + return { status: "cancelled" }; + } + + const parsed = parseFile(format, text); + + if (!parsed.ok) { + return { status: "failed", message: parsed.error }; + } + + const { data } = parsed; + + if (data.detected === 0) { + return { status: "empty" }; + } + + if (data.passwords.length === 0) { + return { status: "failed", message: describeNothingValid(data.detected) }; + } + + let existing: ImportSession["existing"]; + + try { + const rows = await repositories.passwords.getAll(); + + existing = rows.map((row) => ({ + id: row.id, + domain: row.domain, + username: row.username, + password: row.password, + updatedAt: row.updatedAt, + })); + } catch { + return { + status: "failed", + message: "Your saved passwords couldn't be read. Nothing was imported.", + }; + } + + return { + status: "ready", + session: { + format, + parsed: data, + plan: analyzeImport(data.passwords, existing), + existing, + }, + }; +} + +function normalizeCategoryName(name: string): string { + return name.trim().replace(/\s+/g, " ").toLowerCase(); +} + +/** + * Maps the file's category `_id`s to database ids, reusing categories that + * already exist (same name, case-insensitive) and creating the rest. + */ +async function resolveCategories( + candidates: readonly CandidateCategory[], + repository: ImportRepositories["categories"], +): Promise<{ + idBySource: Map; + created: number; + failed: number; +}> { + const idBySource = new Map(); + + if (candidates.length === 0) { + return { idBySource, created: 0, failed: 0 }; + } + + const idByName = new Map(); + + try { + for (const category of await repository.getAll()) { + idByName.set(normalizeCategoryName(category.name), category.id); + } + } catch { + return { idBySource, created: 0, failed: candidates.length }; + } + + let created = 0; + let failed = 0; + + for (const candidate of candidates) { + const key = normalizeCategoryName(candidate.name); + const knownId = idByName.get(key); + + if (knownId !== undefined) { + idBySource.set(candidate.sourceId, knownId); + continue; + } + + try { + const category = await repository.create(candidate.name); + idByName.set(key, category.id); + idBySource.set(candidate.sourceId, category.id); + created += 1; + } catch { + // Invalid or reserved name: its passwords are imported as Unsorted. + failed += 1; + } + } + + return { idBySource, created, failed }; +} + +function summarizeFailures( + reasons: readonly FailureReason[], +): FailureSummaryLine[] { + const counts = new Map(); + + for (const reason of reasons) { + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + + return [...counts].map(([reason, count]) => ({ + message: FAILURE_REASON_TEXT[reason], + count, + })); +} + +/** Never throws. Writes the chosen records and reports what was committed. */ +export async function commitImport( + session: ImportSession, + decisions: ImportDecisions, + repositories: ImportRepositories, +): Promise { + const { parsed, plan } = session; + const warnings = [...parsed.warnings]; + + const chosen = selectedIndexes(plan, decisions); + const categories = await resolveCategories( + parsed.categories, + repositories.categories, + ); + + if (categories.failed > 0) { + warnings.push( + `${categories.failed} categor${categories.failed === 1 ? "y" : "ies"} couldn't be created; their passwords were imported as Unsorted.`, + ); + } + + const candidates = chosen.flatMap((index) => { + const candidate = parsed.passwords[index]; + return candidate ? [{ index, candidate }] : []; + }); + + const rows: ImportPasswordRow[] = candidates.map(({ candidate }) => ({ + domain: candidate.domain, + username: candidate.username, + password: candidate.password, + notes: candidate.notes, + url: candidate.url, + categoryId: + candidate.categorySourceId === null + ? null + : (categories.idBySource.get(candidate.categorySourceId) ?? null), + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + })); + + let insertedRowIndexes: number[] = []; + let failedRowIndexes: number[] = []; + + if (rows.length > 0) { + try { + const result = await repositories.passwords.importMany(rows); + insertedRowIndexes = result.insertedIndexes; + failedRowIndexes = result.failedIndexes; + } catch { + failedRowIndexes = rows.map((_, index) => index); + } + } + + if (rows.length > 0 && insertedRowIndexes.length === 0) { + return { + status: "failed", + message: "The passwords couldn't be saved. Nothing was imported.", + }; + } + + const insertedCandidateIndexes = new Set( + insertedRowIndexes.flatMap((rowIndex) => { + const entry = candidates[rowIndex]; + return entry ? [entry.index] : []; + }), + ); + + let removed = 0; + const idsToRemove = removableExistingIds( + plan, + decisions, + insertedCandidateIndexes, + ); + + if (idsToRemove.length > 0) { + try { + await repositories.passwords.deleteMany(idsToRemove); + removed = idsToRemove.length; + } catch { + warnings.push("Some existing passwords couldn't be removed."); + } + } + + const passwordFailures = parsed.failures.filter( + (failure) => failure.table === "passwords", + ); + + const failureReasons: FailureReason[] = [ + ...passwordFailures.map((failure) => failure.reason), + ...failedRowIndexes.map((): FailureReason => "save-failed"), + ]; + + return { + status: "completed", + detected: parsed.detected, + imported: insertedRowIndexes.length, + skipped: plan.duplicates + skippedByUser(plan, decisions), + failed: failureReasons.length, + categoriesCreated: categories.created, + removed, + failureSummary: summarizeFailures(failureReasons), + warnings, + }; +} diff --git a/src/features/import-export/types.ts b/src/features/import-export/types.ts new file mode 100644 index 00000000..5666f474 --- /dev/null +++ b/src/features/import-export/types.ts @@ -0,0 +1,211 @@ +/** + * Shared types for the import/export feature. + * + * Pure types and constants only (no React Native / Expo imports), so the + * format, analysis and service modules can run in plain Node for tests. + */ + +// --------------------------------------------------------------------------- +// Formats +// --------------------------------------------------------------------------- + +/** Formats the Import and Export screens offer. */ +export type TransferFormat = "google-csv" | "passcodes-json"; + +export interface TransferFormatOption { + value: TransferFormat; + label: string; + description: string; +} + +export const TRANSFER_FORMAT_OPTIONS: readonly TransferFormatOption[] = [ + { + value: "google-csv", + label: "Google CSV", + description: + "Google Password Manager CSV (name, url, username, password, note). Notes and categories are limited to what the CSV can hold; timestamps are not kept.", + }, + { + value: "passcodes-json", + label: "Passcodes JSON", + description: + "Passcodes backup. Keeps notes, categories and timestamps. Not encrypted.", + }, +]; + +// --------------------------------------------------------------------------- +// Parsed (format independent) import data +// --------------------------------------------------------------------------- + +/** One password as read from a file, before it is compared with the database. */ +export interface CandidatePassword { + domain: string; + url: string | null; + username: string; + password: string; + notes: string | null; + /** `_id` of the category in the same file. `null` = Unsorted. */ + categorySourceId: number | null; + /** Raw stored timestamp, or `null` to use the import time. */ + createdAt: string | null; + updatedAt: string | null; +} + +export interface CandidateCategory { + /** `_id` of the category in the file. */ + sourceId: number; + name: string; + createdAt: string | null; + updatedAt: string | null; +} + +/** + * Why a record was rejected. These are fixed codes, never values from the + * file, so they are safe to show and log. + */ +export type FailureReason = + | "missing-domain" + | "missing-username" + | "missing-password" + | "column-mismatch" + | "malformed-row" + | "invalid-record" + | "invalid-field" + | "invalid-id" + | "duplicate-id" + | "save-failed"; + +export const FAILURE_REASON_TEXT: Record = { + "missing-domain": "Missing a domain or site name", + "missing-username": "Missing a username", + "missing-password": "Missing a password", + "column-mismatch": "Wrong number of columns", + "malformed-row": "Malformed quoting", + "invalid-record": "Not a valid record", + "invalid-field": "A field has the wrong type", + "invalid-id": "Missing or invalid _id", + "duplicate-id": "Repeats an _id used earlier in the file", + "save-failed": "Could not be saved", +}; + +export interface RecordFailure { + table: "passwords" | "categories"; + /** 1-based position of the record (CSV data row / JSON array index + 1). */ + position: number; + reason: FailureReason; +} + +export interface ParsedImport { + passwords: CandidatePassword[]; + categories: CandidateCategory[]; + failures: RecordFailure[]; + /** Safe, non-sensitive notes about things that were adjusted. */ + warnings: string[]; + /** Password records found in the file, valid or not. */ + detected: number; +} + +/** `ok: false` is a file-level error: nothing in the file can be trusted. */ +export type ParseResult = + | { ok: true; data: ParsedImport } + | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Duplicate analysis +// --------------------------------------------------------------------------- + +/** What the importer needs to know about a password already on the device. */ +export interface ExistingPassword { + id: number; + domain: string; + username: string; + password: string; + updatedAt: string | null; +} + +/** + * Passwords with the same domain and username but different passwords. + * The user decides what to do with them. + */ +export interface ConflictGroup { + key: string; + /** Display values (from the first incoming record). */ + domain: string; + username: string; + existingIds: number[]; + /** Indexes into `ParsedImport.passwords`. */ + incomingIndexes: number[]; +} + +export interface ImportPlan { + /** Indexes into `ParsedImport.passwords` that can be imported without asking. */ + fresh: number[]; + conflicts: ConflictGroup[]; + /** Records skipped because domain, username AND password already exist. */ + duplicates: number; +} + +export interface ImportDecisions { + /** Conflicting incoming records (indexes) the user wants to import. */ + importIndexes: ReadonlySet; + /** Existing passwords (ids) the user wants removed after importing. */ + removeExistingIds: ReadonlySet; +} + +/** Everything the review step needs. Holds plaintext passwords: keep it in memory only. */ +export interface ImportSession { + format: TransferFormat; + parsed: ParsedImport; + plan: ImportPlan; + existing: ExistingPassword[]; +} + +// --------------------------------------------------------------------------- +// Outcomes +// --------------------------------------------------------------------------- + +export interface FailureSummaryLine { + message: string; + count: number; +} + +/** + * Result of an import. Messages never contain imported values, only + * counts and fixed text, so they are safe to display and log. + */ +export type ImportOutcome = + /** The user closed the file picker. Not an error. */ + | { status: "cancelled" } + /** The file was readable but held no password records. */ + | { status: "empty" } + | { + status: "completed"; + /** Password records found in the file. */ + detected: number; + /** Passwords written to the database. */ + imported: number; + /** Records intentionally not imported (duplicates, or skipped by the user). */ + skipped: number; + /** Records rejected as invalid, or that could not be saved. */ + failed: number; + categoriesCreated: number; + /** Existing passwords removed at the user's request. */ + removed: number; + failureSummary: FailureSummaryLine[]; + warnings: string[]; + } + /** File-level failure: nothing was imported. */ + | { status: "failed"; message: string }; + +export type PrepareOutcome = + | Extract + | { status: "ready"; session: ImportSession }; + +export type ExportOutcome = + /** There are no passwords to export. */ + | { status: "empty" } + /** The user closed the Save As dialog. Not an error. */ + | { status: "cancelled" } + /** The system reported the file as written. */ + | { status: "saved"; count: number; fileName: string } + | { status: "failed"; message: string }; diff --git a/src/features/import-export/utils/duplicates.ts b/src/features/import-export/utils/duplicates.ts new file mode 100644 index 00000000..8d5e9079 --- /dev/null +++ b/src/features/import-export/utils/duplicates.ts @@ -0,0 +1,173 @@ +/** + * Duplicate analysis for imports. Pure functions, no I/O. + * + * Policy (nothing is ever overwritten silently): + * - domain + username + password all equal to an existing password, or to an + * earlier record of the same file -> exact duplicate, skipped. + * - domain + username equal but the password differs -> conflict. The user + * reviews every conflict; by default both versions are kept. + * - anything else -> imported without asking. + * + * Domains compare after trimming and lowercasing (hostnames are not case + * sensitive). Usernames and passwords compare exactly: discarding a record is + * the destructive direction, so when in doubt the record is kept. + */ + +import type { + CandidatePassword, + ConflictGroup, + ExistingPassword, + ImportDecisions, + ImportPlan, +} from "../types"; + +const SEPARATOR = "\u0000"; + +export function normalizeDomain(domain: string): string { + return domain.trim().toLowerCase(); +} + +function identityKey(domain: string, username: string): string { + return `${normalizeDomain(domain)}${SEPARATOR}${username}`; +} + +function exactKey(domain: string, username: string, password: string): string { + return `${identityKey(domain, username)}${SEPARATOR}${password}`; +} + +export function analyzeImport( + candidates: readonly CandidatePassword[], + existing: readonly ExistingPassword[], +): ImportPlan { + const exactSeen = new Set(); + const existingByIdentity = new Map(); + + for (const row of existing) { + exactSeen.add(exactKey(row.domain, row.username, row.password)); + + const key = identityKey(row.domain, row.username); + const ids = existingByIdentity.get(key); + + if (ids) { + ids.push(row.id); + } else { + existingByIdentity.set(key, [row.id]); + } + } + + let duplicates = 0; + const incomingByIdentity = new Map(); + + candidates.forEach((candidate, index) => { + const exact = exactKey( + candidate.domain, + candidate.username, + candidate.password, + ); + + if (exactSeen.has(exact)) { + duplicates += 1; + return; + } + + exactSeen.add(exact); + + const key = identityKey(candidate.domain, candidate.username); + const indexes = incomingByIdentity.get(key); + + if (indexes) { + indexes.push(index); + } else { + incomingByIdentity.set(key, [index]); + } + }); + + const fresh: number[] = []; + const conflicts: ConflictGroup[] = []; + + for (const [key, incomingIndexes] of incomingByIdentity) { + const existingIds = existingByIdentity.get(key) ?? []; + const firstIndex = incomingIndexes[0]; + + if (existingIds.length === 0 && incomingIndexes.length === 1) { + if (firstIndex !== undefined) { + fresh.push(firstIndex); + } + continue; + } + + const first = firstIndex === undefined ? undefined : candidates[firstIndex]; + + conflicts.push({ + key, + domain: first?.domain ?? "", + username: first?.username ?? "", + existingIds, + incomingIndexes, + }); + } + + fresh.sort((a, b) => a - b); + conflicts.sort( + (a, b) => (a.incomingIndexes[0] ?? 0) - (b.incomingIndexes[0] ?? 0), + ); + + return { fresh, conflicts, duplicates }; +} + +/** Default review state: keep both versions of every conflict, remove nothing. */ +export function defaultDecisions(plan: ImportPlan): ImportDecisions { + return { + importIndexes: new Set(plan.conflicts.flatMap((c) => c.incomingIndexes)), + removeExistingIds: new Set(), + }; +} + +/** Indexes of the incoming records to write, in file order. */ +export function selectedIndexes( + plan: ImportPlan, + decisions: ImportDecisions, +): number[] { + const chosen = plan.conflicts + .flatMap((group) => group.incomingIndexes) + .filter((index) => decisions.importIndexes.has(index)); + + return [...plan.fresh, ...chosen].sort((a, b) => a - b); +} + +/** Conflicting incoming records the user chose not to import. */ +export function skippedByUser( + plan: ImportPlan, + decisions: ImportDecisions, +): number { + return plan.conflicts + .flatMap((group) => group.incomingIndexes) + .filter((index) => !decisions.importIndexes.has(index)).length; +} + +/** + * Existing passwords to remove. Only applied to a group when at least one of + * its incoming records was really written, so a failed import can never + * cost the user the password it was meant to replace. + */ +export function removableExistingIds( + plan: ImportPlan, + decisions: ImportDecisions, + insertedIndexes: ReadonlySet, +): number[] { + const ids: number[] = []; + + for (const group of plan.conflicts) { + if (!group.incomingIndexes.some((index) => insertedIndexes.has(index))) { + continue; + } + + for (const id of group.existingIds) { + if (decisions.removeExistingIds.has(id)) { + ids.push(id); + } + } + } + + return ids; +} diff --git a/src/libs/exporting.ts b/src/libs/exporting.ts deleted file mode 100644 index 5085276c..00000000 --- a/src/libs/exporting.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { File, Paths } from "expo-file-system"; -import * as Sharing from "expo-sharing"; - -export type PasswordCSVFormat = { - domain: string; - url: string | null; - username: string; - password: string; - notes: string | null; -}; - -export async function getGooglePasswordsCSVContent( - passwords: PasswordCSVFormat[], -) { - let result = ["name,url,username,password,note"]; - - passwords.forEach((password) => { - let parseURL = password.url || `https://local.${password.domain}`; - - let CSVRow = `${password.domain},${parseURL},${password.username},${password.password},${password.notes || ""}`; - result.push(CSVRow); - }); - - return result.join("\n"); -} - -export async function sharePasswordAsCSV(csvContent: string) { - const file = new File(Paths.cache, `passcodes-export-${Date.now()}.csv`); - - file.write(csvContent); - - try { - await Sharing.shareAsync(file.uri, { - mimeType: "text/csv", - dialogTitle: "Export Passwords", - }); - } finally { - file.delete(); - } -} diff --git a/src/libs/importing.ts b/src/libs/importing.ts deleted file mode 100644 index 81cf8da9..00000000 --- a/src/libs/importing.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { File } from "expo-file-system"; -import { type PasswordCSVFormat } from "./exporting"; - -export async function getCSVPasswordString(): Promise { - let filePickResult = await File.pickFileAsync({ - mimeTypes: [ - "text/csv", - "text/comma-separated-values", - "application/csv", - "text/plain", - ], - }); - - if (filePickResult.canceled) { - return "name,url,username,password,note"; - } - - let passwordFile = filePickResult.result; - let passwordCSVContent = await passwordFile.text(); - return passwordCSVContent; -} - -export function convertRawCSVToPasswords( - CSVContent: string, -): PasswordCSVFormat[] { - const lines = CSVContent.trim().split("\n").filter(Boolean); - - // remove header - lines.shift(); - - return lines.map((line) => { - // TODO: Assigning empty string is tempoary solution, we will improve this later. - const [domain = "", url = "", username = "", password = "", notes = ""] = - line.split(","); - - return { - domain: domain, - url: url, - username: username, - password: password, - notes: notes, - }; - }); -} diff --git a/src/repositories/PasswordRepository.ts b/src/repositories/PasswordRepository.ts index 05b6a47c..f6ebe2c8 100644 --- a/src/repositories/PasswordRepository.ts +++ b/src/repositories/PasswordRepository.ts @@ -5,6 +5,9 @@ import { eq, inArray, isNull, sql } from "drizzle-orm"; /** SQLite limits the number of bound variables, so big batches are chunked. */ const BATCH_SIZE = 500; +/** Rows per transaction when importing. */ +const IMPORT_CHUNK_SIZE = 200; + function chunk(items: T[], size: number): T[][] { const chunks: T[][] = []; @@ -25,6 +28,25 @@ export interface CreatePasswordInput { categoryId?: number | null; } +/** One password to import. Timestamps are kept as given; `null` = now. */ +export interface ImportPasswordRow { + domain: string; + username: string; + password: string; + notes: string | null; + url: string | null; + categoryId: number | null; + createdAt: string | null; + updatedAt: string | null; +} + +export interface ImportManyResult { + /** Indexes (into the rows passed in) that were committed. */ + insertedIndexes: number[]; + /** Indexes that could not be written. */ + failedIndexes: number[]; +} + export interface UpdatePasswordInput { domain?: string; username?: string; @@ -75,6 +97,15 @@ export class PasswordRepository { return this.observeAll(); } + /** Total number of stored passwords. */ + async count() { + const [row] = await this.db + .select({ count: sql`count(*)` }) + .from(passwords); + + return Number(row?.count ?? 0); + } + async getById(id: number) { const [password] = await this.db .select() @@ -93,21 +124,57 @@ export class PasswordRepository { return createdPassword; } - // TODO: temporary solution we will create a upsert all method - async importAll(data: CreatePasswordInput[]) { - this.db.transaction((tx) => { - data.forEach((importablePassword) => { - tx.insert(passwords) - .values({ - domain: importablePassword.domain, - username: importablePassword.username, - password: importablePassword.password, - notes: importablePassword.notes, - url: importablePassword.url, - }) - .execute(); - }); + /** + * Inserts imported passwords, preserving their timestamps (`null` = use + * the database default, i.e. now). + * + * Rows are written in chunks, one transaction per chunk. If a chunk fails + * it is rolled back and retried row by row, so one bad row never costs the + * others. The result reports what was REALLY committed, as indexes into + * `rows`. + */ + async importMany(rows: readonly ImportPasswordRow[]): Promise { + const insertedIndexes: number[] = []; + const failedIndexes: number[] = []; + + const toValues = (row: ImportPasswordRow) => ({ + domain: row.domain, + username: row.username, + password: row.password, + notes: row.notes, + url: row.url, + categoryId: row.categoryId, + createdAt: row.createdAt ?? undefined, + updatedAt: row.updatedAt ?? undefined, }); + + for (let start = 0; start < rows.length; start += IMPORT_CHUNK_SIZE) { + const slice = rows.slice(start, start + IMPORT_CHUNK_SIZE); + + try { + this.db.transaction((tx) => { + for (const row of slice) { + tx.insert(passwords).values(toValues(row)).run(); + } + }); + + slice.forEach((_, offset) => insertedIndexes.push(start + offset)); + } catch { + slice.forEach((row, offset) => { + try { + this.db.insert(passwords).values(toValues(row)).run(); + insertedIndexes.push(start + offset); + } catch { + failedIndexes.push(start + offset); + } + }); + } + + // The SQLite driver is synchronous: give the UI thread a turn. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + return { insertedIndexes, failedIndexes }; } async update(id: number, data: UpdatePasswordInput) {