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) {