Malachite is a tool to import your Last.fm and Spotify listening history to the AT Protocol network using the fm.teal.alpha.feed.play lexicon.
malachite scrobbles importer atproto music
15
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: extract shared environment-agnostic core and migrate to pnpm workspace

This refactor removes duplicated logic between the CLI and the web app by introducing a shared, environment-agnostic core. It also restructures the project as a proper pnpm workspace.

Key changes:

- Monorepo setup:
- Added `pnpm-workspace.yaml`
- Updated root `package.json` to define workspaces (`packages/*`, `web`)
- Introduced `build:packages` and `build:all` scripts

- Shared core extraction:
- Created `src/core/` for environment-agnostic domain logic
- Moved modules including `auth`, `car-fetch`, `csv`, `merge`, `publisher`,
`rate-limiter`, `spotify`, `sync`, `tid`, and `types` into the shared core

- Web app updates:
- Replaced duplicated browser-specific implementations with re-exports from `src/core/`
- Added `$core` alias in `svelte.config.js` pointing to `../src/core`
- Configured Vite `fs.allow` to support workspace root imports

- CLI adjustments:
- Refactored `src/lib/` to act as thin wrappers around `src/core/`
- Reintroduced Node-specific concerns (e.g. `fs` access, terminal auth prompts) at the CLI layer

- Version bumps:
- Root package → `0.10.1`
- Web package → `0.3.1`

+3757 -1523
+7 -1
package.json
··· 1 1 { 2 2 "name": "malachite", 3 - "version": "0.10.0", 3 + "version": "0.10.1", 4 4 "description": "Malachite - Import Last.fm and Spotify listening history to ATProto with intelligent deduplication and rate limiting", 5 5 "type": "module", 6 6 "main": "./dist/index.js", ··· 8 8 "bin": { 9 9 "malachite": "./dist/index.js" 10 10 }, 11 + "workspaces": [ 12 + "packages/*", 13 + "web" 14 + ], 11 15 "scripts": { 12 16 "build": "tsc", 17 + "build:packages": "pnpm --filter './packages/*' build", 18 + "build:all": "pnpm run build:packages && pnpm run build", 13 19 "start": "node dist/index.js", 14 20 "dev": "tsc && node dist/index.js", 15 21 "dry-run": "npm run build && node dist/index.js --dry-run",
+2387
pnpm-lock.yaml
··· 43 43 specifier: ^5.9.3 44 44 version: 5.9.3 45 45 46 + packages/tid: 47 + devDependencies: 48 + tsup: 49 + specifier: ^8.5.0 50 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3) 51 + typescript: 52 + specifier: ^5.9.3 53 + version: 5.9.3 54 + 55 + web: 56 + dependencies: 57 + '@atproto/api': 58 + specifier: ^0.18.13 59 + version: 0.18.15 60 + '@atproto/common-web': 61 + specifier: ^0.4.12 62 + version: 0.4.12 63 + '@atproto/oauth-client-browser': 64 + specifier: ^0.3.41 65 + version: 0.3.41 66 + '@ipld/car': 67 + specifier: ^5.3.2 68 + version: 5.4.2 69 + '@ipld/dag-cbor': 70 + specifier: ^9.2.2 71 + version: 9.2.5 72 + '@lucide/svelte': 73 + specifier: ^0.575.0 74 + version: 0.575.0(svelte@5.53.7) 75 + multiformats: 76 + specifier: ^13.3.1 77 + version: 13.4.2 78 + devDependencies: 79 + '@sveltejs/adapter-vercel': 80 + specifier: ^6.3.1 81 + version: 6.3.3(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(rollup@4.59.0) 82 + '@sveltejs/kit': 83 + specifier: ^2.50.2 84 + version: 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 85 + '@sveltejs/vite-plugin-svelte': 86 + specifier: ^6.2.4 87 + version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 88 + '@tailwindcss/vite': 89 + specifier: ^4.1.18 90 + version: 4.2.1(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 91 + prettier: 92 + specifier: ^3.8.1 93 + version: 3.8.1 94 + prettier-plugin-svelte: 95 + specifier: ^3.4.1 96 + version: 3.5.1(prettier@3.8.1)(svelte@5.53.7) 97 + prettier-plugin-tailwindcss: 98 + specifier: ^0.7.2 99 + version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7))(prettier@3.8.1) 100 + svelte: 101 + specifier: ^5.51.0 102 + version: 5.53.7 103 + svelte-check: 104 + specifier: ^4.3.6 105 + version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3) 106 + tailwindcss: 107 + specifier: ^4.1.18 108 + version: 4.2.1 109 + typescript: 110 + specifier: ^5.9.3 111 + version: 5.9.3 112 + vite: 113 + specifier: ^7.3.1 114 + version: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1) 115 + 46 116 packages: 47 117 118 + '@atproto-labs/did-resolver@0.2.6': 119 + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} 120 + 121 + '@atproto-labs/fetch@0.2.3': 122 + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} 123 + 124 + '@atproto-labs/handle-resolver@0.3.6': 125 + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} 126 + 127 + '@atproto-labs/identity-resolver@0.3.6': 128 + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} 129 + 130 + '@atproto-labs/pipe@0.1.1': 131 + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} 132 + 133 + '@atproto-labs/simple-store-memory@0.1.4': 134 + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} 135 + 136 + '@atproto-labs/simple-store@0.3.0': 137 + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 138 + 48 139 '@atproto/api@0.18.15': 49 140 resolution: {integrity: sha512-GeaTP7HMRZa8jD6trMuTACa8t2jkFtRmcwWgrB0FT7l9jVCXrKpYupWeIeauEgWHNwWUUiaq3LmCox+HBy8ZMQ==} 50 141 51 142 '@atproto/common-web@0.4.12': 52 143 resolution: {integrity: sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==} 53 144 145 + '@atproto/did@0.3.0': 146 + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} 147 + 148 + '@atproto/jwk-jose@0.1.11': 149 + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} 150 + 151 + '@atproto/jwk-webcrypto@0.2.0': 152 + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} 153 + 154 + '@atproto/jwk@0.6.0': 155 + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 156 + 54 157 '@atproto/lex-data@0.0.8': 55 158 resolution: {integrity: sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==} 56 159 ··· 60 163 '@atproto/lexicon@0.6.0': 61 164 resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} 62 165 166 + '@atproto/oauth-client-browser@0.3.41': 167 + resolution: {integrity: sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g==} 168 + 169 + '@atproto/oauth-client@0.6.0': 170 + resolution: {integrity: sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==} 171 + 172 + '@atproto/oauth-types@0.6.3': 173 + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} 174 + 63 175 '@atproto/syntax@0.4.2': 64 176 resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} 65 177 66 178 '@atproto/xrpc@0.7.7': 67 179 resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} 68 180 181 + '@esbuild/aix-ppc64@0.25.12': 182 + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} 183 + engines: {node: '>=18'} 184 + cpu: [ppc64] 185 + os: [aix] 186 + 187 + '@esbuild/aix-ppc64@0.27.3': 188 + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} 189 + engines: {node: '>=18'} 190 + cpu: [ppc64] 191 + os: [aix] 192 + 193 + '@esbuild/android-arm64@0.25.12': 194 + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} 195 + engines: {node: '>=18'} 196 + cpu: [arm64] 197 + os: [android] 198 + 199 + '@esbuild/android-arm64@0.27.3': 200 + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} 201 + engines: {node: '>=18'} 202 + cpu: [arm64] 203 + os: [android] 204 + 205 + '@esbuild/android-arm@0.25.12': 206 + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} 207 + engines: {node: '>=18'} 208 + cpu: [arm] 209 + os: [android] 210 + 211 + '@esbuild/android-arm@0.27.3': 212 + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} 213 + engines: {node: '>=18'} 214 + cpu: [arm] 215 + os: [android] 216 + 217 + '@esbuild/android-x64@0.25.12': 218 + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} 219 + engines: {node: '>=18'} 220 + cpu: [x64] 221 + os: [android] 222 + 223 + '@esbuild/android-x64@0.27.3': 224 + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} 225 + engines: {node: '>=18'} 226 + cpu: [x64] 227 + os: [android] 228 + 229 + '@esbuild/darwin-arm64@0.25.12': 230 + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} 231 + engines: {node: '>=18'} 232 + cpu: [arm64] 233 + os: [darwin] 234 + 235 + '@esbuild/darwin-arm64@0.27.3': 236 + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} 237 + engines: {node: '>=18'} 238 + cpu: [arm64] 239 + os: [darwin] 240 + 241 + '@esbuild/darwin-x64@0.25.12': 242 + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} 243 + engines: {node: '>=18'} 244 + cpu: [x64] 245 + os: [darwin] 246 + 247 + '@esbuild/darwin-x64@0.27.3': 248 + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} 249 + engines: {node: '>=18'} 250 + cpu: [x64] 251 + os: [darwin] 252 + 253 + '@esbuild/freebsd-arm64@0.25.12': 254 + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} 255 + engines: {node: '>=18'} 256 + cpu: [arm64] 257 + os: [freebsd] 258 + 259 + '@esbuild/freebsd-arm64@0.27.3': 260 + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} 261 + engines: {node: '>=18'} 262 + cpu: [arm64] 263 + os: [freebsd] 264 + 265 + '@esbuild/freebsd-x64@0.25.12': 266 + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} 267 + engines: {node: '>=18'} 268 + cpu: [x64] 269 + os: [freebsd] 270 + 271 + '@esbuild/freebsd-x64@0.27.3': 272 + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} 273 + engines: {node: '>=18'} 274 + cpu: [x64] 275 + os: [freebsd] 276 + 277 + '@esbuild/linux-arm64@0.25.12': 278 + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} 279 + engines: {node: '>=18'} 280 + cpu: [arm64] 281 + os: [linux] 282 + 283 + '@esbuild/linux-arm64@0.27.3': 284 + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} 285 + engines: {node: '>=18'} 286 + cpu: [arm64] 287 + os: [linux] 288 + 289 + '@esbuild/linux-arm@0.25.12': 290 + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} 291 + engines: {node: '>=18'} 292 + cpu: [arm] 293 + os: [linux] 294 + 295 + '@esbuild/linux-arm@0.27.3': 296 + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} 297 + engines: {node: '>=18'} 298 + cpu: [arm] 299 + os: [linux] 300 + 301 + '@esbuild/linux-ia32@0.25.12': 302 + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} 303 + engines: {node: '>=18'} 304 + cpu: [ia32] 305 + os: [linux] 306 + 307 + '@esbuild/linux-ia32@0.27.3': 308 + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} 309 + engines: {node: '>=18'} 310 + cpu: [ia32] 311 + os: [linux] 312 + 313 + '@esbuild/linux-loong64@0.25.12': 314 + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} 315 + engines: {node: '>=18'} 316 + cpu: [loong64] 317 + os: [linux] 318 + 319 + '@esbuild/linux-loong64@0.27.3': 320 + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} 321 + engines: {node: '>=18'} 322 + cpu: [loong64] 323 + os: [linux] 324 + 325 + '@esbuild/linux-mips64el@0.25.12': 326 + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} 327 + engines: {node: '>=18'} 328 + cpu: [mips64el] 329 + os: [linux] 330 + 331 + '@esbuild/linux-mips64el@0.27.3': 332 + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} 333 + engines: {node: '>=18'} 334 + cpu: [mips64el] 335 + os: [linux] 336 + 337 + '@esbuild/linux-ppc64@0.25.12': 338 + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} 339 + engines: {node: '>=18'} 340 + cpu: [ppc64] 341 + os: [linux] 342 + 343 + '@esbuild/linux-ppc64@0.27.3': 344 + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} 345 + engines: {node: '>=18'} 346 + cpu: [ppc64] 347 + os: [linux] 348 + 349 + '@esbuild/linux-riscv64@0.25.12': 350 + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} 351 + engines: {node: '>=18'} 352 + cpu: [riscv64] 353 + os: [linux] 354 + 355 + '@esbuild/linux-riscv64@0.27.3': 356 + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} 357 + engines: {node: '>=18'} 358 + cpu: [riscv64] 359 + os: [linux] 360 + 361 + '@esbuild/linux-s390x@0.25.12': 362 + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} 363 + engines: {node: '>=18'} 364 + cpu: [s390x] 365 + os: [linux] 366 + 367 + '@esbuild/linux-s390x@0.27.3': 368 + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} 369 + engines: {node: '>=18'} 370 + cpu: [s390x] 371 + os: [linux] 372 + 373 + '@esbuild/linux-x64@0.25.12': 374 + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} 375 + engines: {node: '>=18'} 376 + cpu: [x64] 377 + os: [linux] 378 + 379 + '@esbuild/linux-x64@0.27.3': 380 + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} 381 + engines: {node: '>=18'} 382 + cpu: [x64] 383 + os: [linux] 384 + 385 + '@esbuild/netbsd-arm64@0.25.12': 386 + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} 387 + engines: {node: '>=18'} 388 + cpu: [arm64] 389 + os: [netbsd] 390 + 391 + '@esbuild/netbsd-arm64@0.27.3': 392 + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} 393 + engines: {node: '>=18'} 394 + cpu: [arm64] 395 + os: [netbsd] 396 + 397 + '@esbuild/netbsd-x64@0.25.12': 398 + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} 399 + engines: {node: '>=18'} 400 + cpu: [x64] 401 + os: [netbsd] 402 + 403 + '@esbuild/netbsd-x64@0.27.3': 404 + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} 405 + engines: {node: '>=18'} 406 + cpu: [x64] 407 + os: [netbsd] 408 + 409 + '@esbuild/openbsd-arm64@0.25.12': 410 + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} 411 + engines: {node: '>=18'} 412 + cpu: [arm64] 413 + os: [openbsd] 414 + 415 + '@esbuild/openbsd-arm64@0.27.3': 416 + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} 417 + engines: {node: '>=18'} 418 + cpu: [arm64] 419 + os: [openbsd] 420 + 421 + '@esbuild/openbsd-x64@0.25.12': 422 + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} 423 + engines: {node: '>=18'} 424 + cpu: [x64] 425 + os: [openbsd] 426 + 427 + '@esbuild/openbsd-x64@0.27.3': 428 + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} 429 + engines: {node: '>=18'} 430 + cpu: [x64] 431 + os: [openbsd] 432 + 433 + '@esbuild/openharmony-arm64@0.25.12': 434 + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} 435 + engines: {node: '>=18'} 436 + cpu: [arm64] 437 + os: [openharmony] 438 + 439 + '@esbuild/openharmony-arm64@0.27.3': 440 + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} 441 + engines: {node: '>=18'} 442 + cpu: [arm64] 443 + os: [openharmony] 444 + 445 + '@esbuild/sunos-x64@0.25.12': 446 + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} 447 + engines: {node: '>=18'} 448 + cpu: [x64] 449 + os: [sunos] 450 + 451 + '@esbuild/sunos-x64@0.27.3': 452 + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} 453 + engines: {node: '>=18'} 454 + cpu: [x64] 455 + os: [sunos] 456 + 457 + '@esbuild/win32-arm64@0.25.12': 458 + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} 459 + engines: {node: '>=18'} 460 + cpu: [arm64] 461 + os: [win32] 462 + 463 + '@esbuild/win32-arm64@0.27.3': 464 + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} 465 + engines: {node: '>=18'} 466 + cpu: [arm64] 467 + os: [win32] 468 + 469 + '@esbuild/win32-ia32@0.25.12': 470 + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} 471 + engines: {node: '>=18'} 472 + cpu: [ia32] 473 + os: [win32] 474 + 475 + '@esbuild/win32-ia32@0.27.3': 476 + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} 477 + engines: {node: '>=18'} 478 + cpu: [ia32] 479 + os: [win32] 480 + 481 + '@esbuild/win32-x64@0.25.12': 482 + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} 483 + engines: {node: '>=18'} 484 + cpu: [x64] 485 + os: [win32] 486 + 487 + '@esbuild/win32-x64@0.27.3': 488 + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} 489 + engines: {node: '>=18'} 490 + cpu: [x64] 491 + os: [win32] 492 + 69 493 '@ipld/car@5.4.2': 70 494 resolution: {integrity: sha512-gfyrJvePyXnh2Fbj8mPg4JYvEZ3izhk8C9WgAle7xIYbrJNSXmNQ6BxAls8Gof97vvGbCROdxbTWRmHJtTCbcg==} 71 495 engines: {node: '>=16.0.0', npm: '>=7.0.0'} ··· 74 498 resolution: {integrity: sha512-84wSr4jv30biui7endhobYhXBQzQE4c/wdoWlFrKcfiwH+ofaPg8fwsM8okX9cOzkkrsAsNdDyH3ou+kiLquwQ==} 75 499 engines: {node: '>=16.0.0', npm: '>=7.0.0'} 76 500 501 + '@isaacs/fs-minipass@4.0.1': 502 + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} 503 + engines: {node: '>=18.0.0'} 504 + 505 + '@jridgewell/gen-mapping@0.3.13': 506 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} 507 + 508 + '@jridgewell/remapping@2.3.5': 509 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} 510 + 511 + '@jridgewell/resolve-uri@3.1.2': 512 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 513 + engines: {node: '>=6.0.0'} 514 + 515 + '@jridgewell/sourcemap-codec@1.5.5': 516 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 517 + 518 + '@jridgewell/trace-mapping@0.3.31': 519 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 520 + 521 + '@lucide/svelte@0.575.0': 522 + resolution: {integrity: sha512-FEFp/0McZwsjBqh1Dn8H+UBm1yHFQYk+utuVMFDw57155+wz2XMoc1pw027ylCPzs+bi14UEXYKbekFhuJKtnw==} 523 + peerDependencies: 524 + svelte: ^5 525 + 526 + '@mapbox/node-pre-gyp@2.0.3': 527 + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} 528 + engines: {node: '>=18'} 529 + hasBin: true 530 + 531 + '@polka/url@1.0.0-next.29': 532 + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} 533 + 534 + '@rollup/pluginutils@5.3.0': 535 + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} 536 + engines: {node: '>=14.0.0'} 537 + peerDependencies: 538 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 539 + peerDependenciesMeta: 540 + rollup: 541 + optional: true 542 + 543 + '@rollup/rollup-android-arm-eabi@4.59.0': 544 + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} 545 + cpu: [arm] 546 + os: [android] 547 + 548 + '@rollup/rollup-android-arm64@4.59.0': 549 + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} 550 + cpu: [arm64] 551 + os: [android] 552 + 553 + '@rollup/rollup-darwin-arm64@4.59.0': 554 + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} 555 + cpu: [arm64] 556 + os: [darwin] 557 + 558 + '@rollup/rollup-darwin-x64@4.59.0': 559 + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} 560 + cpu: [x64] 561 + os: [darwin] 562 + 563 + '@rollup/rollup-freebsd-arm64@4.59.0': 564 + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} 565 + cpu: [arm64] 566 + os: [freebsd] 567 + 568 + '@rollup/rollup-freebsd-x64@4.59.0': 569 + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} 570 + cpu: [x64] 571 + os: [freebsd] 572 + 573 + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': 574 + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} 575 + cpu: [arm] 576 + os: [linux] 577 + 578 + '@rollup/rollup-linux-arm-musleabihf@4.59.0': 579 + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} 580 + cpu: [arm] 581 + os: [linux] 582 + 583 + '@rollup/rollup-linux-arm64-gnu@4.59.0': 584 + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} 585 + cpu: [arm64] 586 + os: [linux] 587 + 588 + '@rollup/rollup-linux-arm64-musl@4.59.0': 589 + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} 590 + cpu: [arm64] 591 + os: [linux] 592 + 593 + '@rollup/rollup-linux-loong64-gnu@4.59.0': 594 + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} 595 + cpu: [loong64] 596 + os: [linux] 597 + 598 + '@rollup/rollup-linux-loong64-musl@4.59.0': 599 + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} 600 + cpu: [loong64] 601 + os: [linux] 602 + 603 + '@rollup/rollup-linux-ppc64-gnu@4.59.0': 604 + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} 605 + cpu: [ppc64] 606 + os: [linux] 607 + 608 + '@rollup/rollup-linux-ppc64-musl@4.59.0': 609 + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} 610 + cpu: [ppc64] 611 + os: [linux] 612 + 613 + '@rollup/rollup-linux-riscv64-gnu@4.59.0': 614 + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} 615 + cpu: [riscv64] 616 + os: [linux] 617 + 618 + '@rollup/rollup-linux-riscv64-musl@4.59.0': 619 + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} 620 + cpu: [riscv64] 621 + os: [linux] 622 + 623 + '@rollup/rollup-linux-s390x-gnu@4.59.0': 624 + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} 625 + cpu: [s390x] 626 + os: [linux] 627 + 628 + '@rollup/rollup-linux-x64-gnu@4.59.0': 629 + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} 630 + cpu: [x64] 631 + os: [linux] 632 + 633 + '@rollup/rollup-linux-x64-musl@4.59.0': 634 + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} 635 + cpu: [x64] 636 + os: [linux] 637 + 638 + '@rollup/rollup-openbsd-x64@4.59.0': 639 + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} 640 + cpu: [x64] 641 + os: [openbsd] 642 + 643 + '@rollup/rollup-openharmony-arm64@4.59.0': 644 + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} 645 + cpu: [arm64] 646 + os: [openharmony] 647 + 648 + '@rollup/rollup-win32-arm64-msvc@4.59.0': 649 + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} 650 + cpu: [arm64] 651 + os: [win32] 652 + 653 + '@rollup/rollup-win32-ia32-msvc@4.59.0': 654 + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} 655 + cpu: [ia32] 656 + os: [win32] 657 + 658 + '@rollup/rollup-win32-x64-gnu@4.59.0': 659 + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} 660 + cpu: [x64] 661 + os: [win32] 662 + 663 + '@rollup/rollup-win32-x64-msvc@4.59.0': 664 + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} 665 + cpu: [x64] 666 + os: [win32] 667 + 668 + '@standard-schema/spec@1.1.0': 669 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 670 + 671 + '@sveltejs/acorn-typescript@1.0.9': 672 + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} 673 + peerDependencies: 674 + acorn: ^8.9.0 675 + 676 + '@sveltejs/adapter-vercel@6.3.3': 677 + resolution: {integrity: sha512-jI7jT/XqRyFe9oqKvFcNPQfyNBi3pXqN1iQXa2lmeKT5Vzgr9iSOqJOD3pXf/9Q2Os6SXzqYYm6osRjHYEhkyw==} 678 + engines: {node: '>=20.0'} 679 + peerDependencies: 680 + '@sveltejs/kit': ^2.4.0 681 + 682 + '@sveltejs/kit@2.53.4': 683 + resolution: {integrity: sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==} 684 + engines: {node: '>=18.13'} 685 + hasBin: true 686 + peerDependencies: 687 + '@opentelemetry/api': ^1.0.0 688 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 689 + svelte: ^4.0.0 || ^5.0.0-next.0 690 + typescript: ^5.3.3 691 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 692 + peerDependenciesMeta: 693 + '@opentelemetry/api': 694 + optional: true 695 + typescript: 696 + optional: true 697 + 698 + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': 699 + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} 700 + engines: {node: ^20.19 || ^22.12 || >=24} 701 + peerDependencies: 702 + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 703 + svelte: ^5.0.0 704 + vite: ^6.3.0 || ^7.0.0 705 + 706 + '@sveltejs/vite-plugin-svelte@6.2.4': 707 + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} 708 + engines: {node: ^20.19 || ^22.12 || >=24} 709 + peerDependencies: 710 + svelte: ^5.0.0 711 + vite: ^6.3.0 || ^7.0.0 712 + 713 + '@tailwindcss/node@4.2.1': 714 + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} 715 + 716 + '@tailwindcss/oxide-android-arm64@4.2.1': 717 + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} 718 + engines: {node: '>= 20'} 719 + cpu: [arm64] 720 + os: [android] 721 + 722 + '@tailwindcss/oxide-darwin-arm64@4.2.1': 723 + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} 724 + engines: {node: '>= 20'} 725 + cpu: [arm64] 726 + os: [darwin] 727 + 728 + '@tailwindcss/oxide-darwin-x64@4.2.1': 729 + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} 730 + engines: {node: '>= 20'} 731 + cpu: [x64] 732 + os: [darwin] 733 + 734 + '@tailwindcss/oxide-freebsd-x64@4.2.1': 735 + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} 736 + engines: {node: '>= 20'} 737 + cpu: [x64] 738 + os: [freebsd] 739 + 740 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': 741 + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} 742 + engines: {node: '>= 20'} 743 + cpu: [arm] 744 + os: [linux] 745 + 746 + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': 747 + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} 748 + engines: {node: '>= 20'} 749 + cpu: [arm64] 750 + os: [linux] 751 + 752 + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': 753 + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} 754 + engines: {node: '>= 20'} 755 + cpu: [arm64] 756 + os: [linux] 757 + 758 + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': 759 + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} 760 + engines: {node: '>= 20'} 761 + cpu: [x64] 762 + os: [linux] 763 + 764 + '@tailwindcss/oxide-linux-x64-musl@4.2.1': 765 + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} 766 + engines: {node: '>= 20'} 767 + cpu: [x64] 768 + os: [linux] 769 + 770 + '@tailwindcss/oxide-wasm32-wasi@4.2.1': 771 + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} 772 + engines: {node: '>=14.0.0'} 773 + cpu: [wasm32] 774 + bundledDependencies: 775 + - '@napi-rs/wasm-runtime' 776 + - '@emnapi/core' 777 + - '@emnapi/runtime' 778 + - '@tybys/wasm-util' 779 + - '@emnapi/wasi-threads' 780 + - tslib 781 + 782 + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': 783 + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} 784 + engines: {node: '>= 20'} 785 + cpu: [arm64] 786 + os: [win32] 787 + 788 + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': 789 + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} 790 + engines: {node: '>= 20'} 791 + cpu: [x64] 792 + os: [win32] 793 + 794 + '@tailwindcss/oxide@4.2.1': 795 + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} 796 + engines: {node: '>= 20'} 797 + 798 + '@tailwindcss/vite@4.2.1': 799 + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} 800 + peerDependencies: 801 + vite: ^5.2.0 || ^6 || ^7 802 + 803 + '@types/cookie@0.6.0': 804 + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 805 + 806 + '@types/estree@1.0.8': 807 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 808 + 77 809 '@types/node@20.19.27': 78 810 resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} 79 811 812 + '@types/trusted-types@2.0.7': 813 + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 814 + 815 + '@vercel/nft@1.3.2': 816 + resolution: {integrity: sha512-HC8venRc4Ya7vNeBsJneKHHMDDWpQie7VaKhAIOst3MKO+DES+Y/SbzSp8mFkD7OzwAE2HhHkeSuSmwS20mz3A==} 817 + engines: {node: '>=20'} 818 + hasBin: true 819 + 820 + abbrev@3.0.1: 821 + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} 822 + engines: {node: ^18.17.0 || >=20.5.0} 823 + 824 + acorn-import-attributes@1.9.5: 825 + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} 826 + peerDependencies: 827 + acorn: ^8 828 + 829 + acorn@8.16.0: 830 + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} 831 + engines: {node: '>=0.4.0'} 832 + hasBin: true 833 + 834 + agent-base@7.1.4: 835 + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} 836 + engines: {node: '>= 14'} 837 + 80 838 ansi-regex@5.0.1: 81 839 resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 82 840 engines: {node: '>=8'} ··· 85 843 resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} 86 844 engines: {node: '>=12'} 87 845 846 + any-promise@1.3.0: 847 + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} 848 + 849 + aria-query@5.3.1: 850 + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} 851 + engines: {node: '>= 0.4'} 852 + 853 + async-sema@3.1.1: 854 + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} 855 + 88 856 await-lock@2.2.2: 89 857 resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 90 858 859 + axobject-query@4.1.0: 860 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 861 + engines: {node: '>= 0.4'} 862 + 863 + balanced-match@4.0.4: 864 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} 865 + engines: {node: 18 || 20 || >=22} 866 + 867 + bindings@1.5.0: 868 + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} 869 + 870 + brace-expansion@5.0.4: 871 + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} 872 + engines: {node: 18 || 20 || >=22} 873 + 874 + bundle-require@5.1.0: 875 + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} 876 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 877 + peerDependencies: 878 + esbuild: '>=0.18' 879 + 880 + cac@6.7.14: 881 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} 882 + engines: {node: '>=8'} 883 + 91 884 cborg@4.5.8: 92 885 resolution: {integrity: sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==} 93 886 hasBin: true ··· 96 889 resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} 97 890 engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} 98 891 892 + chokidar@4.0.3: 893 + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 894 + engines: {node: '>= 14.16.0'} 895 + 896 + chownr@3.0.0: 897 + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} 898 + engines: {node: '>=18'} 899 + 99 900 cli-cursor@5.0.0: 100 901 resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} 101 902 engines: {node: '>=18'} ··· 108 909 resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} 109 910 engines: {node: '>=18.20'} 110 911 912 + clsx@2.1.1: 913 + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} 914 + engines: {node: '>=6'} 915 + 916 + commander@4.1.1: 917 + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} 918 + engines: {node: '>= 6'} 919 + 920 + confbox@0.1.8: 921 + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} 922 + 923 + consola@3.4.2: 924 + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} 925 + engines: {node: ^14.18.0 || >=16.10.0} 926 + 927 + cookie@0.6.0: 928 + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 929 + engines: {node: '>= 0.6'} 930 + 931 + core-js@3.48.0: 932 + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} 933 + 111 934 csv-parse@6.1.0: 112 935 resolution: {integrity: sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==} 113 936 937 + debug@4.4.3: 938 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 939 + engines: {node: '>=6.0'} 940 + peerDependencies: 941 + supports-color: '*' 942 + peerDependenciesMeta: 943 + supports-color: 944 + optional: true 945 + 946 + deepmerge@4.3.1: 947 + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} 948 + engines: {node: '>=0.10.0'} 949 + 950 + detect-libc@2.1.2: 951 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 952 + engines: {node: '>=8'} 953 + 954 + devalue@5.6.3: 955 + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} 956 + 114 957 emoji-regex@8.0.0: 115 958 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 116 959 960 + enhanced-resolve@5.20.0: 961 + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} 962 + engines: {node: '>=10.13.0'} 963 + 964 + esbuild@0.25.12: 965 + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} 966 + engines: {node: '>=18'} 967 + hasBin: true 968 + 969 + esbuild@0.27.3: 970 + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} 971 + engines: {node: '>=18'} 972 + hasBin: true 973 + 974 + esm-env@1.2.2: 975 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 976 + 977 + esrap@2.2.3: 978 + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} 979 + 980 + estree-walker@2.0.2: 981 + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} 982 + 983 + fdir@6.5.0: 984 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 985 + engines: {node: '>=12.0.0'} 986 + peerDependencies: 987 + picomatch: ^3 || ^4 988 + peerDependenciesMeta: 989 + picomatch: 990 + optional: true 991 + 992 + file-uri-to-path@1.0.0: 993 + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} 994 + 995 + fix-dts-default-cjs-exports@1.0.1: 996 + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} 997 + 998 + fsevents@2.3.3: 999 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 1000 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 1001 + os: [darwin] 1002 + 117 1003 get-east-asian-width@1.4.0: 118 1004 resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} 119 1005 engines: {node: '>=18'} 120 1006 1007 + glob@13.0.6: 1008 + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} 1009 + engines: {node: 18 || 20 || >=22} 1010 + 1011 + graceful-fs@4.2.11: 1012 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 1013 + 1014 + https-proxy-agent@7.0.6: 1015 + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} 1016 + engines: {node: '>= 14'} 1017 + 121 1018 is-fullwidth-code-point@3.0.0: 122 1019 resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 123 1020 engines: {node: '>=8'} ··· 126 1023 resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} 127 1024 engines: {node: '>=12'} 128 1025 1026 + is-reference@3.0.3: 1027 + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 1028 + 129 1029 is-unicode-supported@2.1.0: 130 1030 resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} 131 1031 engines: {node: '>=18'} ··· 133 1033 iso-datestring-validator@2.2.2: 134 1034 resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 135 1035 1036 + jiti@2.6.1: 1037 + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 1038 + hasBin: true 1039 + 1040 + jose@5.10.0: 1041 + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 1042 + 1043 + joycon@3.1.1: 1044 + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} 1045 + engines: {node: '>=10'} 1046 + 1047 + kleur@4.1.5: 1048 + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 1049 + engines: {node: '>=6'} 1050 + 1051 + lightningcss-android-arm64@1.31.1: 1052 + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} 1053 + engines: {node: '>= 12.0.0'} 1054 + cpu: [arm64] 1055 + os: [android] 1056 + 1057 + lightningcss-darwin-arm64@1.31.1: 1058 + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} 1059 + engines: {node: '>= 12.0.0'} 1060 + cpu: [arm64] 1061 + os: [darwin] 1062 + 1063 + lightningcss-darwin-x64@1.31.1: 1064 + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} 1065 + engines: {node: '>= 12.0.0'} 1066 + cpu: [x64] 1067 + os: [darwin] 1068 + 1069 + lightningcss-freebsd-x64@1.31.1: 1070 + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} 1071 + engines: {node: '>= 12.0.0'} 1072 + cpu: [x64] 1073 + os: [freebsd] 1074 + 1075 + lightningcss-linux-arm-gnueabihf@1.31.1: 1076 + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} 1077 + engines: {node: '>= 12.0.0'} 1078 + cpu: [arm] 1079 + os: [linux] 1080 + 1081 + lightningcss-linux-arm64-gnu@1.31.1: 1082 + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} 1083 + engines: {node: '>= 12.0.0'} 1084 + cpu: [arm64] 1085 + os: [linux] 1086 + 1087 + lightningcss-linux-arm64-musl@1.31.1: 1088 + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} 1089 + engines: {node: '>= 12.0.0'} 1090 + cpu: [arm64] 1091 + os: [linux] 1092 + 1093 + lightningcss-linux-x64-gnu@1.31.1: 1094 + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} 1095 + engines: {node: '>= 12.0.0'} 1096 + cpu: [x64] 1097 + os: [linux] 1098 + 1099 + lightningcss-linux-x64-musl@1.31.1: 1100 + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} 1101 + engines: {node: '>= 12.0.0'} 1102 + cpu: [x64] 1103 + os: [linux] 1104 + 1105 + lightningcss-win32-arm64-msvc@1.31.1: 1106 + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} 1107 + engines: {node: '>= 12.0.0'} 1108 + cpu: [arm64] 1109 + os: [win32] 1110 + 1111 + lightningcss-win32-x64-msvc@1.31.1: 1112 + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} 1113 + engines: {node: '>= 12.0.0'} 1114 + cpu: [x64] 1115 + os: [win32] 1116 + 1117 + lightningcss@1.31.1: 1118 + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} 1119 + engines: {node: '>= 12.0.0'} 1120 + 1121 + lilconfig@3.1.3: 1122 + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} 1123 + engines: {node: '>=14'} 1124 + 1125 + lines-and-columns@1.2.4: 1126 + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} 1127 + 1128 + load-tsconfig@0.2.5: 1129 + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} 1130 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 1131 + 1132 + locate-character@3.0.0: 1133 + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} 1134 + 136 1135 log-symbols@7.0.1: 137 1136 resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} 138 1137 engines: {node: '>=18'} 139 1138 1139 + lru-cache@10.4.3: 1140 + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 1141 + 1142 + lru-cache@11.2.6: 1143 + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} 1144 + engines: {node: 20 || >=22} 1145 + 1146 + magic-string@0.30.21: 1147 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 1148 + 140 1149 mimic-function@5.0.1: 141 1150 resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} 142 1151 engines: {node: '>=18'} 143 1152 1153 + minimatch@10.2.4: 1154 + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} 1155 + engines: {node: 18 || 20 || >=22} 1156 + 1157 + minipass@7.1.3: 1158 + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} 1159 + engines: {node: '>=16 || 14 >=14.17'} 1160 + 1161 + minizlib@3.1.0: 1162 + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} 1163 + engines: {node: '>= 18'} 1164 + 1165 + mlly@1.8.0: 1166 + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} 1167 + 1168 + mri@1.2.0: 1169 + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 1170 + engines: {node: '>=4'} 1171 + 1172 + mrmime@2.0.1: 1173 + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} 1174 + engines: {node: '>=10'} 1175 + 1176 + ms@2.1.3: 1177 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1178 + 144 1179 multiformats@13.4.2: 145 1180 resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} 146 1181 147 1182 multiformats@9.9.0: 148 1183 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 149 1184 1185 + mz@2.7.0: 1186 + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} 1187 + 1188 + nanoid@3.3.11: 1189 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 1190 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 1191 + hasBin: true 1192 + 1193 + node-fetch@2.7.0: 1194 + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} 1195 + engines: {node: 4.x || >=6.0.0} 1196 + peerDependencies: 1197 + encoding: ^0.1.0 1198 + peerDependenciesMeta: 1199 + encoding: 1200 + optional: true 1201 + 1202 + node-gyp-build@4.8.4: 1203 + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} 1204 + hasBin: true 1205 + 1206 + nopt@8.1.0: 1207 + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} 1208 + engines: {node: ^18.17.0 || >=20.5.0} 1209 + hasBin: true 1210 + 1211 + object-assign@4.1.1: 1212 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} 1213 + engines: {node: '>=0.10.0'} 1214 + 1215 + obug@2.1.1: 1216 + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 1217 + 150 1218 onetime@7.0.0: 151 1219 resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} 152 1220 engines: {node: '>=18'} ··· 155 1223 resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} 156 1224 engines: {node: '>=20'} 157 1225 1226 + path-scurry@2.0.2: 1227 + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} 1228 + engines: {node: 18 || 20 || >=22} 1229 + 1230 + pathe@2.0.3: 1231 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 1232 + 1233 + picocolors@1.1.1: 1234 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 1235 + 1236 + picomatch@4.0.3: 1237 + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 1238 + engines: {node: '>=12'} 1239 + 1240 + pirates@4.0.7: 1241 + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} 1242 + engines: {node: '>= 6'} 1243 + 1244 + pkg-types@1.3.1: 1245 + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} 1246 + 1247 + postcss-load-config@6.0.1: 1248 + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} 1249 + engines: {node: '>= 18'} 1250 + peerDependencies: 1251 + jiti: '>=1.21.0' 1252 + postcss: '>=8.0.9' 1253 + tsx: ^4.8.1 1254 + yaml: ^2.4.2 1255 + peerDependenciesMeta: 1256 + jiti: 1257 + optional: true 1258 + postcss: 1259 + optional: true 1260 + tsx: 1261 + optional: true 1262 + yaml: 1263 + optional: true 1264 + 1265 + postcss@8.5.8: 1266 + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} 1267 + engines: {node: ^10 || ^12 || >=14} 1268 + 1269 + prettier-plugin-svelte@3.5.1: 1270 + resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==} 1271 + peerDependencies: 1272 + prettier: ^3.0.0 1273 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 1274 + 1275 + prettier-plugin-tailwindcss@0.7.2: 1276 + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} 1277 + engines: {node: '>=20.19'} 1278 + peerDependencies: 1279 + '@ianvs/prettier-plugin-sort-imports': '*' 1280 + '@prettier/plugin-hermes': '*' 1281 + '@prettier/plugin-oxc': '*' 1282 + '@prettier/plugin-pug': '*' 1283 + '@shopify/prettier-plugin-liquid': '*' 1284 + '@trivago/prettier-plugin-sort-imports': '*' 1285 + '@zackad/prettier-plugin-twig': '*' 1286 + prettier: ^3.0 1287 + prettier-plugin-astro: '*' 1288 + prettier-plugin-css-order: '*' 1289 + prettier-plugin-jsdoc: '*' 1290 + prettier-plugin-marko: '*' 1291 + prettier-plugin-multiline-arrays: '*' 1292 + prettier-plugin-organize-attributes: '*' 1293 + prettier-plugin-organize-imports: '*' 1294 + prettier-plugin-sort-imports: '*' 1295 + prettier-plugin-svelte: '*' 1296 + peerDependenciesMeta: 1297 + '@ianvs/prettier-plugin-sort-imports': 1298 + optional: true 1299 + '@prettier/plugin-hermes': 1300 + optional: true 1301 + '@prettier/plugin-oxc': 1302 + optional: true 1303 + '@prettier/plugin-pug': 1304 + optional: true 1305 + '@shopify/prettier-plugin-liquid': 1306 + optional: true 1307 + '@trivago/prettier-plugin-sort-imports': 1308 + optional: true 1309 + '@zackad/prettier-plugin-twig': 1310 + optional: true 1311 + prettier-plugin-astro: 1312 + optional: true 1313 + prettier-plugin-css-order: 1314 + optional: true 1315 + prettier-plugin-jsdoc: 1316 + optional: true 1317 + prettier-plugin-marko: 1318 + optional: true 1319 + prettier-plugin-multiline-arrays: 1320 + optional: true 1321 + prettier-plugin-organize-attributes: 1322 + optional: true 1323 + prettier-plugin-organize-imports: 1324 + optional: true 1325 + prettier-plugin-sort-imports: 1326 + optional: true 1327 + prettier-plugin-svelte: 1328 + optional: true 1329 + 1330 + prettier@3.8.1: 1331 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 1332 + engines: {node: '>=14'} 1333 + hasBin: true 1334 + 1335 + readdirp@4.1.2: 1336 + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 1337 + engines: {node: '>= 14.18.0'} 1338 + 1339 + resolve-from@5.0.0: 1340 + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} 1341 + engines: {node: '>=8'} 1342 + 158 1343 restore-cursor@5.1.0: 159 1344 resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} 160 1345 engines: {node: '>=18'} 161 1346 1347 + rollup@4.59.0: 1348 + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} 1349 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1350 + hasBin: true 1351 + 1352 + sade@1.8.1: 1353 + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 1354 + engines: {node: '>=6'} 1355 + 1356 + semver@7.7.4: 1357 + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} 1358 + engines: {node: '>=10'} 1359 + hasBin: true 1360 + 1361 + set-cookie-parser@3.0.1: 1362 + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} 1363 + 162 1364 signal-exit@4.1.0: 163 1365 resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 164 1366 engines: {node: '>=14'} 165 1367 1368 + sirv@3.0.2: 1369 + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} 1370 + engines: {node: '>=18'} 1371 + 1372 + source-map-js@1.2.1: 1373 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 1374 + engines: {node: '>=0.10.0'} 1375 + 1376 + source-map@0.7.6: 1377 + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} 1378 + engines: {node: '>= 12'} 1379 + 166 1380 stdin-discarder@0.2.2: 167 1381 resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} 168 1382 engines: {node: '>=18'} ··· 183 1397 resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} 184 1398 engines: {node: '>=12'} 185 1399 1400 + sucrase@3.35.1: 1401 + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} 1402 + engines: {node: '>=16 || 14 >=14.17'} 1403 + hasBin: true 1404 + 1405 + svelte-check@4.4.4: 1406 + resolution: {integrity: sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==} 1407 + engines: {node: '>= 18.0.0'} 1408 + hasBin: true 1409 + peerDependencies: 1410 + svelte: ^4.0.0 || ^5.0.0-next.0 1411 + typescript: '>=5.0.0' 1412 + 1413 + svelte@5.53.7: 1414 + resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} 1415 + engines: {node: '>=18'} 1416 + 1417 + tailwindcss@4.2.1: 1418 + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} 1419 + 1420 + tapable@2.3.0: 1421 + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} 1422 + engines: {node: '>=6'} 1423 + 1424 + tar@7.5.9: 1425 + resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} 1426 + engines: {node: '>=18'} 1427 + 1428 + thenify-all@1.6.0: 1429 + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} 1430 + engines: {node: '>=0.8'} 1431 + 1432 + thenify@3.3.1: 1433 + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} 1434 + 1435 + tinyexec@0.3.2: 1436 + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} 1437 + 1438 + tinyglobby@0.2.15: 1439 + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 1440 + engines: {node: '>=12.0.0'} 1441 + 186 1442 tlds@1.261.0: 187 1443 resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 188 1444 hasBin: true 189 1445 1446 + totalist@3.0.1: 1447 + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 1448 + engines: {node: '>=6'} 1449 + 1450 + tr46@0.0.3: 1451 + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} 1452 + 1453 + tree-kill@1.2.2: 1454 + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} 1455 + hasBin: true 1456 + 1457 + ts-interface-checker@0.1.13: 1458 + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} 1459 + 190 1460 tslib@2.8.1: 191 1461 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 192 1462 1463 + tsup@8.5.1: 1464 + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} 1465 + engines: {node: '>=18'} 1466 + hasBin: true 1467 + peerDependencies: 1468 + '@microsoft/api-extractor': ^7.36.0 1469 + '@swc/core': ^1 1470 + postcss: ^8.4.12 1471 + typescript: '>=4.5.0' 1472 + peerDependenciesMeta: 1473 + '@microsoft/api-extractor': 1474 + optional: true 1475 + '@swc/core': 1476 + optional: true 1477 + postcss: 1478 + optional: true 1479 + typescript: 1480 + optional: true 1481 + 193 1482 typescript@5.9.3: 194 1483 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 195 1484 engines: {node: '>=14.17'} 196 1485 hasBin: true 1486 + 1487 + ufo@1.6.3: 1488 + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} 197 1489 198 1490 uint8arrays@3.0.0: 199 1491 resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} ··· 207 1499 varint@6.0.0: 208 1500 resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} 209 1501 1502 + vite@7.3.1: 1503 + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} 1504 + engines: {node: ^20.19.0 || >=22.12.0} 1505 + hasBin: true 1506 + peerDependencies: 1507 + '@types/node': ^20.19.0 || >=22.12.0 1508 + jiti: '>=1.21.0' 1509 + less: ^4.0.0 1510 + lightningcss: ^1.21.0 1511 + sass: ^1.70.0 1512 + sass-embedded: ^1.70.0 1513 + stylus: '>=0.54.8' 1514 + sugarss: ^5.0.0 1515 + terser: ^5.16.0 1516 + tsx: ^4.8.1 1517 + yaml: ^2.4.2 1518 + peerDependenciesMeta: 1519 + '@types/node': 1520 + optional: true 1521 + jiti: 1522 + optional: true 1523 + less: 1524 + optional: true 1525 + lightningcss: 1526 + optional: true 1527 + sass: 1528 + optional: true 1529 + sass-embedded: 1530 + optional: true 1531 + stylus: 1532 + optional: true 1533 + sugarss: 1534 + optional: true 1535 + terser: 1536 + optional: true 1537 + tsx: 1538 + optional: true 1539 + yaml: 1540 + optional: true 1541 + 1542 + vitefu@1.1.2: 1543 + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} 1544 + peerDependencies: 1545 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 1546 + peerDependenciesMeta: 1547 + vite: 1548 + optional: true 1549 + 1550 + webidl-conversions@3.0.1: 1551 + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} 1552 + 1553 + whatwg-url@5.0.0: 1554 + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} 1555 + 1556 + yallist@5.0.0: 1557 + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} 1558 + engines: {node: '>=18'} 1559 + 210 1560 yoctocolors@2.1.2: 211 1561 resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} 212 1562 engines: {node: '>=18'} 213 1563 1564 + zimmerframe@1.1.4: 1565 + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 1566 + 214 1567 zod@3.25.76: 215 1568 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 216 1569 217 1570 snapshots: 218 1571 1572 + '@atproto-labs/did-resolver@0.2.6': 1573 + dependencies: 1574 + '@atproto-labs/fetch': 0.2.3 1575 + '@atproto-labs/pipe': 0.1.1 1576 + '@atproto-labs/simple-store': 0.3.0 1577 + '@atproto-labs/simple-store-memory': 0.1.4 1578 + '@atproto/did': 0.3.0 1579 + zod: 3.25.76 1580 + 1581 + '@atproto-labs/fetch@0.2.3': 1582 + dependencies: 1583 + '@atproto-labs/pipe': 0.1.1 1584 + 1585 + '@atproto-labs/handle-resolver@0.3.6': 1586 + dependencies: 1587 + '@atproto-labs/simple-store': 0.3.0 1588 + '@atproto-labs/simple-store-memory': 0.1.4 1589 + '@atproto/did': 0.3.0 1590 + zod: 3.25.76 1591 + 1592 + '@atproto-labs/identity-resolver@0.3.6': 1593 + dependencies: 1594 + '@atproto-labs/did-resolver': 0.2.6 1595 + '@atproto-labs/handle-resolver': 0.3.6 1596 + 1597 + '@atproto-labs/pipe@0.1.1': {} 1598 + 1599 + '@atproto-labs/simple-store-memory@0.1.4': 1600 + dependencies: 1601 + '@atproto-labs/simple-store': 0.3.0 1602 + lru-cache: 10.4.3 1603 + 1604 + '@atproto-labs/simple-store@0.3.0': {} 1605 + 219 1606 '@atproto/api@0.18.15': 220 1607 dependencies: 221 1608 '@atproto/common-web': 0.4.12 ··· 233 1620 '@atproto/lex-json': 0.0.8 234 1621 zod: 3.25.76 235 1622 1623 + '@atproto/did@0.3.0': 1624 + dependencies: 1625 + zod: 3.25.76 1626 + 1627 + '@atproto/jwk-jose@0.1.11': 1628 + dependencies: 1629 + '@atproto/jwk': 0.6.0 1630 + jose: 5.10.0 1631 + 1632 + '@atproto/jwk-webcrypto@0.2.0': 1633 + dependencies: 1634 + '@atproto/jwk': 0.6.0 1635 + '@atproto/jwk-jose': 0.1.11 1636 + zod: 3.25.76 1637 + 1638 + '@atproto/jwk@0.6.0': 1639 + dependencies: 1640 + multiformats: 9.9.0 1641 + zod: 3.25.76 1642 + 236 1643 '@atproto/lex-data@0.0.8': 237 1644 dependencies: 238 1645 '@atproto/syntax': 0.4.2 ··· 254 1661 multiformats: 9.9.0 255 1662 zod: 3.25.76 256 1663 1664 + '@atproto/oauth-client-browser@0.3.41': 1665 + dependencies: 1666 + '@atproto-labs/did-resolver': 0.2.6 1667 + '@atproto-labs/handle-resolver': 0.3.6 1668 + '@atproto-labs/simple-store': 0.3.0 1669 + '@atproto/did': 0.3.0 1670 + '@atproto/jwk': 0.6.0 1671 + '@atproto/jwk-webcrypto': 0.2.0 1672 + '@atproto/oauth-client': 0.6.0 1673 + '@atproto/oauth-types': 0.6.3 1674 + core-js: 3.48.0 1675 + 1676 + '@atproto/oauth-client@0.6.0': 1677 + dependencies: 1678 + '@atproto-labs/did-resolver': 0.2.6 1679 + '@atproto-labs/fetch': 0.2.3 1680 + '@atproto-labs/handle-resolver': 0.3.6 1681 + '@atproto-labs/identity-resolver': 0.3.6 1682 + '@atproto-labs/simple-store': 0.3.0 1683 + '@atproto-labs/simple-store-memory': 0.1.4 1684 + '@atproto/did': 0.3.0 1685 + '@atproto/jwk': 0.6.0 1686 + '@atproto/oauth-types': 0.6.3 1687 + '@atproto/xrpc': 0.7.7 1688 + core-js: 3.48.0 1689 + multiformats: 9.9.0 1690 + zod: 3.25.76 1691 + 1692 + '@atproto/oauth-types@0.6.3': 1693 + dependencies: 1694 + '@atproto/did': 0.3.0 1695 + '@atproto/jwk': 0.6.0 1696 + zod: 3.25.76 1697 + 257 1698 '@atproto/syntax@0.4.2': {} 258 1699 259 1700 '@atproto/xrpc@0.7.7': ··· 261 1702 '@atproto/lexicon': 0.6.0 262 1703 zod: 3.25.76 263 1704 1705 + '@esbuild/aix-ppc64@0.25.12': 1706 + optional: true 1707 + 1708 + '@esbuild/aix-ppc64@0.27.3': 1709 + optional: true 1710 + 1711 + '@esbuild/android-arm64@0.25.12': 1712 + optional: true 1713 + 1714 + '@esbuild/android-arm64@0.27.3': 1715 + optional: true 1716 + 1717 + '@esbuild/android-arm@0.25.12': 1718 + optional: true 1719 + 1720 + '@esbuild/android-arm@0.27.3': 1721 + optional: true 1722 + 1723 + '@esbuild/android-x64@0.25.12': 1724 + optional: true 1725 + 1726 + '@esbuild/android-x64@0.27.3': 1727 + optional: true 1728 + 1729 + '@esbuild/darwin-arm64@0.25.12': 1730 + optional: true 1731 + 1732 + '@esbuild/darwin-arm64@0.27.3': 1733 + optional: true 1734 + 1735 + '@esbuild/darwin-x64@0.25.12': 1736 + optional: true 1737 + 1738 + '@esbuild/darwin-x64@0.27.3': 1739 + optional: true 1740 + 1741 + '@esbuild/freebsd-arm64@0.25.12': 1742 + optional: true 1743 + 1744 + '@esbuild/freebsd-arm64@0.27.3': 1745 + optional: true 1746 + 1747 + '@esbuild/freebsd-x64@0.25.12': 1748 + optional: true 1749 + 1750 + '@esbuild/freebsd-x64@0.27.3': 1751 + optional: true 1752 + 1753 + '@esbuild/linux-arm64@0.25.12': 1754 + optional: true 1755 + 1756 + '@esbuild/linux-arm64@0.27.3': 1757 + optional: true 1758 + 1759 + '@esbuild/linux-arm@0.25.12': 1760 + optional: true 1761 + 1762 + '@esbuild/linux-arm@0.27.3': 1763 + optional: true 1764 + 1765 + '@esbuild/linux-ia32@0.25.12': 1766 + optional: true 1767 + 1768 + '@esbuild/linux-ia32@0.27.3': 1769 + optional: true 1770 + 1771 + '@esbuild/linux-loong64@0.25.12': 1772 + optional: true 1773 + 1774 + '@esbuild/linux-loong64@0.27.3': 1775 + optional: true 1776 + 1777 + '@esbuild/linux-mips64el@0.25.12': 1778 + optional: true 1779 + 1780 + '@esbuild/linux-mips64el@0.27.3': 1781 + optional: true 1782 + 1783 + '@esbuild/linux-ppc64@0.25.12': 1784 + optional: true 1785 + 1786 + '@esbuild/linux-ppc64@0.27.3': 1787 + optional: true 1788 + 1789 + '@esbuild/linux-riscv64@0.25.12': 1790 + optional: true 1791 + 1792 + '@esbuild/linux-riscv64@0.27.3': 1793 + optional: true 1794 + 1795 + '@esbuild/linux-s390x@0.25.12': 1796 + optional: true 1797 + 1798 + '@esbuild/linux-s390x@0.27.3': 1799 + optional: true 1800 + 1801 + '@esbuild/linux-x64@0.25.12': 1802 + optional: true 1803 + 1804 + '@esbuild/linux-x64@0.27.3': 1805 + optional: true 1806 + 1807 + '@esbuild/netbsd-arm64@0.25.12': 1808 + optional: true 1809 + 1810 + '@esbuild/netbsd-arm64@0.27.3': 1811 + optional: true 1812 + 1813 + '@esbuild/netbsd-x64@0.25.12': 1814 + optional: true 1815 + 1816 + '@esbuild/netbsd-x64@0.27.3': 1817 + optional: true 1818 + 1819 + '@esbuild/openbsd-arm64@0.25.12': 1820 + optional: true 1821 + 1822 + '@esbuild/openbsd-arm64@0.27.3': 1823 + optional: true 1824 + 1825 + '@esbuild/openbsd-x64@0.25.12': 1826 + optional: true 1827 + 1828 + '@esbuild/openbsd-x64@0.27.3': 1829 + optional: true 1830 + 1831 + '@esbuild/openharmony-arm64@0.25.12': 1832 + optional: true 1833 + 1834 + '@esbuild/openharmony-arm64@0.27.3': 1835 + optional: true 1836 + 1837 + '@esbuild/sunos-x64@0.25.12': 1838 + optional: true 1839 + 1840 + '@esbuild/sunos-x64@0.27.3': 1841 + optional: true 1842 + 1843 + '@esbuild/win32-arm64@0.25.12': 1844 + optional: true 1845 + 1846 + '@esbuild/win32-arm64@0.27.3': 1847 + optional: true 1848 + 1849 + '@esbuild/win32-ia32@0.25.12': 1850 + optional: true 1851 + 1852 + '@esbuild/win32-ia32@0.27.3': 1853 + optional: true 1854 + 1855 + '@esbuild/win32-x64@0.25.12': 1856 + optional: true 1857 + 1858 + '@esbuild/win32-x64@0.27.3': 1859 + optional: true 1860 + 264 1861 '@ipld/car@5.4.2': 265 1862 dependencies: 266 1863 '@ipld/dag-cbor': 9.2.5 ··· 273 1870 cborg: 4.5.8 274 1871 multiformats: 13.4.2 275 1872 1873 + '@isaacs/fs-minipass@4.0.1': 1874 + dependencies: 1875 + minipass: 7.1.3 1876 + 1877 + '@jridgewell/gen-mapping@0.3.13': 1878 + dependencies: 1879 + '@jridgewell/sourcemap-codec': 1.5.5 1880 + '@jridgewell/trace-mapping': 0.3.31 1881 + 1882 + '@jridgewell/remapping@2.3.5': 1883 + dependencies: 1884 + '@jridgewell/gen-mapping': 0.3.13 1885 + '@jridgewell/trace-mapping': 0.3.31 1886 + 1887 + '@jridgewell/resolve-uri@3.1.2': {} 1888 + 1889 + '@jridgewell/sourcemap-codec@1.5.5': {} 1890 + 1891 + '@jridgewell/trace-mapping@0.3.31': 1892 + dependencies: 1893 + '@jridgewell/resolve-uri': 3.1.2 1894 + '@jridgewell/sourcemap-codec': 1.5.5 1895 + 1896 + '@lucide/svelte@0.575.0(svelte@5.53.7)': 1897 + dependencies: 1898 + svelte: 5.53.7 1899 + 1900 + '@mapbox/node-pre-gyp@2.0.3': 1901 + dependencies: 1902 + consola: 3.4.2 1903 + detect-libc: 2.1.2 1904 + https-proxy-agent: 7.0.6 1905 + node-fetch: 2.7.0 1906 + nopt: 8.1.0 1907 + semver: 7.7.4 1908 + tar: 7.5.9 1909 + transitivePeerDependencies: 1910 + - encoding 1911 + - supports-color 1912 + 1913 + '@polka/url@1.0.0-next.29': {} 1914 + 1915 + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': 1916 + dependencies: 1917 + '@types/estree': 1.0.8 1918 + estree-walker: 2.0.2 1919 + picomatch: 4.0.3 1920 + optionalDependencies: 1921 + rollup: 4.59.0 1922 + 1923 + '@rollup/rollup-android-arm-eabi@4.59.0': 1924 + optional: true 1925 + 1926 + '@rollup/rollup-android-arm64@4.59.0': 1927 + optional: true 1928 + 1929 + '@rollup/rollup-darwin-arm64@4.59.0': 1930 + optional: true 1931 + 1932 + '@rollup/rollup-darwin-x64@4.59.0': 1933 + optional: true 1934 + 1935 + '@rollup/rollup-freebsd-arm64@4.59.0': 1936 + optional: true 1937 + 1938 + '@rollup/rollup-freebsd-x64@4.59.0': 1939 + optional: true 1940 + 1941 + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': 1942 + optional: true 1943 + 1944 + '@rollup/rollup-linux-arm-musleabihf@4.59.0': 1945 + optional: true 1946 + 1947 + '@rollup/rollup-linux-arm64-gnu@4.59.0': 1948 + optional: true 1949 + 1950 + '@rollup/rollup-linux-arm64-musl@4.59.0': 1951 + optional: true 1952 + 1953 + '@rollup/rollup-linux-loong64-gnu@4.59.0': 1954 + optional: true 1955 + 1956 + '@rollup/rollup-linux-loong64-musl@4.59.0': 1957 + optional: true 1958 + 1959 + '@rollup/rollup-linux-ppc64-gnu@4.59.0': 1960 + optional: true 1961 + 1962 + '@rollup/rollup-linux-ppc64-musl@4.59.0': 1963 + optional: true 1964 + 1965 + '@rollup/rollup-linux-riscv64-gnu@4.59.0': 1966 + optional: true 1967 + 1968 + '@rollup/rollup-linux-riscv64-musl@4.59.0': 1969 + optional: true 1970 + 1971 + '@rollup/rollup-linux-s390x-gnu@4.59.0': 1972 + optional: true 1973 + 1974 + '@rollup/rollup-linux-x64-gnu@4.59.0': 1975 + optional: true 1976 + 1977 + '@rollup/rollup-linux-x64-musl@4.59.0': 1978 + optional: true 1979 + 1980 + '@rollup/rollup-openbsd-x64@4.59.0': 1981 + optional: true 1982 + 1983 + '@rollup/rollup-openharmony-arm64@4.59.0': 1984 + optional: true 1985 + 1986 + '@rollup/rollup-win32-arm64-msvc@4.59.0': 1987 + optional: true 1988 + 1989 + '@rollup/rollup-win32-ia32-msvc@4.59.0': 1990 + optional: true 1991 + 1992 + '@rollup/rollup-win32-x64-gnu@4.59.0': 1993 + optional: true 1994 + 1995 + '@rollup/rollup-win32-x64-msvc@4.59.0': 1996 + optional: true 1997 + 1998 + '@standard-schema/spec@1.1.0': {} 1999 + 2000 + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': 2001 + dependencies: 2002 + acorn: 8.16.0 2003 + 2004 + '@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(rollup@4.59.0)': 2005 + dependencies: 2006 + '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 2007 + '@vercel/nft': 1.3.2(rollup@4.59.0) 2008 + esbuild: 0.25.12 2009 + transitivePeerDependencies: 2010 + - encoding 2011 + - rollup 2012 + - supports-color 2013 + 2014 + '@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1))': 2015 + dependencies: 2016 + '@standard-schema/spec': 1.1.0 2017 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) 2018 + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 2019 + '@types/cookie': 0.6.0 2020 + acorn: 8.16.0 2021 + cookie: 0.6.0 2022 + devalue: 5.6.3 2023 + esm-env: 1.2.2 2024 + kleur: 4.1.5 2025 + magic-string: 0.30.21 2026 + mrmime: 2.0.1 2027 + set-cookie-parser: 3.0.1 2028 + sirv: 3.0.2 2029 + svelte: 5.53.7 2030 + vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1) 2031 + optionalDependencies: 2032 + typescript: 5.9.3 2033 + 2034 + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1))': 2035 + dependencies: 2036 + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 2037 + obug: 2.1.1 2038 + svelte: 5.53.7 2039 + vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1) 2040 + 2041 + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1))': 2042 + dependencies: 2043 + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 2044 + deepmerge: 4.3.1 2045 + magic-string: 0.30.21 2046 + obug: 2.1.1 2047 + svelte: 5.53.7 2048 + vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1) 2049 + vitefu: 1.1.2(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)) 2050 + 2051 + '@tailwindcss/node@4.2.1': 2052 + dependencies: 2053 + '@jridgewell/remapping': 2.3.5 2054 + enhanced-resolve: 5.20.0 2055 + jiti: 2.6.1 2056 + lightningcss: 1.31.1 2057 + magic-string: 0.30.21 2058 + source-map-js: 1.2.1 2059 + tailwindcss: 4.2.1 2060 + 2061 + '@tailwindcss/oxide-android-arm64@4.2.1': 2062 + optional: true 2063 + 2064 + '@tailwindcss/oxide-darwin-arm64@4.2.1': 2065 + optional: true 2066 + 2067 + '@tailwindcss/oxide-darwin-x64@4.2.1': 2068 + optional: true 2069 + 2070 + '@tailwindcss/oxide-freebsd-x64@4.2.1': 2071 + optional: true 2072 + 2073 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': 2074 + optional: true 2075 + 2076 + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': 2077 + optional: true 2078 + 2079 + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': 2080 + optional: true 2081 + 2082 + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': 2083 + optional: true 2084 + 2085 + '@tailwindcss/oxide-linux-x64-musl@4.2.1': 2086 + optional: true 2087 + 2088 + '@tailwindcss/oxide-wasm32-wasi@4.2.1': 2089 + optional: true 2090 + 2091 + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': 2092 + optional: true 2093 + 2094 + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': 2095 + optional: true 2096 + 2097 + '@tailwindcss/oxide@4.2.1': 2098 + optionalDependencies: 2099 + '@tailwindcss/oxide-android-arm64': 4.2.1 2100 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 2101 + '@tailwindcss/oxide-darwin-x64': 4.2.1 2102 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 2103 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 2104 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 2105 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 2106 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 2107 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 2108 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 2109 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 2110 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 2111 + 2112 + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1))': 2113 + dependencies: 2114 + '@tailwindcss/node': 4.2.1 2115 + '@tailwindcss/oxide': 4.2.1 2116 + tailwindcss: 4.2.1 2117 + vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1) 2118 + 2119 + '@types/cookie@0.6.0': {} 2120 + 2121 + '@types/estree@1.0.8': {} 2122 + 276 2123 '@types/node@20.19.27': 277 2124 dependencies: 278 2125 undici-types: 6.21.0 279 2126 2127 + '@types/trusted-types@2.0.7': {} 2128 + 2129 + '@vercel/nft@1.3.2(rollup@4.59.0)': 2130 + dependencies: 2131 + '@mapbox/node-pre-gyp': 2.0.3 2132 + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) 2133 + acorn: 8.16.0 2134 + acorn-import-attributes: 1.9.5(acorn@8.16.0) 2135 + async-sema: 3.1.1 2136 + bindings: 1.5.0 2137 + estree-walker: 2.0.2 2138 + glob: 13.0.6 2139 + graceful-fs: 4.2.11 2140 + node-gyp-build: 4.8.4 2141 + picomatch: 4.0.3 2142 + resolve-from: 5.0.0 2143 + transitivePeerDependencies: 2144 + - encoding 2145 + - rollup 2146 + - supports-color 2147 + 2148 + abbrev@3.0.1: {} 2149 + 2150 + acorn-import-attributes@1.9.5(acorn@8.16.0): 2151 + dependencies: 2152 + acorn: 8.16.0 2153 + 2154 + acorn@8.16.0: {} 2155 + 2156 + agent-base@7.1.4: {} 2157 + 280 2158 ansi-regex@5.0.1: {} 281 2159 282 2160 ansi-regex@6.2.2: {} 283 2161 2162 + any-promise@1.3.0: {} 2163 + 2164 + aria-query@5.3.1: {} 2165 + 2166 + async-sema@3.1.1: {} 2167 + 284 2168 await-lock@2.2.2: {} 2169 + 2170 + axobject-query@4.1.0: {} 2171 + 2172 + balanced-match@4.0.4: {} 2173 + 2174 + bindings@1.5.0: 2175 + dependencies: 2176 + file-uri-to-path: 1.0.0 2177 + 2178 + brace-expansion@5.0.4: 2179 + dependencies: 2180 + balanced-match: 4.0.4 2181 + 2182 + bundle-require@5.1.0(esbuild@0.27.3): 2183 + dependencies: 2184 + esbuild: 0.27.3 2185 + load-tsconfig: 0.2.5 2186 + 2187 + cac@6.7.14: {} 285 2188 286 2189 cborg@4.5.8: {} 287 2190 288 2191 chalk@5.6.2: {} 289 2192 2193 + chokidar@4.0.3: 2194 + dependencies: 2195 + readdirp: 4.1.2 2196 + 2197 + chownr@3.0.0: {} 2198 + 290 2199 cli-cursor@5.0.0: 291 2200 dependencies: 292 2201 restore-cursor: 5.1.0 ··· 297 2206 298 2207 cli-spinners@3.4.0: {} 299 2208 2209 + clsx@2.1.1: {} 2210 + 2211 + commander@4.1.1: {} 2212 + 2213 + confbox@0.1.8: {} 2214 + 2215 + consola@3.4.2: {} 2216 + 2217 + cookie@0.6.0: {} 2218 + 2219 + core-js@3.48.0: {} 2220 + 300 2221 csv-parse@6.1.0: {} 301 2222 2223 + debug@4.4.3: 2224 + dependencies: 2225 + ms: 2.1.3 2226 + 2227 + deepmerge@4.3.1: {} 2228 + 2229 + detect-libc@2.1.2: {} 2230 + 2231 + devalue@5.6.3: {} 2232 + 302 2233 emoji-regex@8.0.0: {} 303 2234 2235 + enhanced-resolve@5.20.0: 2236 + dependencies: 2237 + graceful-fs: 4.2.11 2238 + tapable: 2.3.0 2239 + 2240 + esbuild@0.25.12: 2241 + optionalDependencies: 2242 + '@esbuild/aix-ppc64': 0.25.12 2243 + '@esbuild/android-arm': 0.25.12 2244 + '@esbuild/android-arm64': 0.25.12 2245 + '@esbuild/android-x64': 0.25.12 2246 + '@esbuild/darwin-arm64': 0.25.12 2247 + '@esbuild/darwin-x64': 0.25.12 2248 + '@esbuild/freebsd-arm64': 0.25.12 2249 + '@esbuild/freebsd-x64': 0.25.12 2250 + '@esbuild/linux-arm': 0.25.12 2251 + '@esbuild/linux-arm64': 0.25.12 2252 + '@esbuild/linux-ia32': 0.25.12 2253 + '@esbuild/linux-loong64': 0.25.12 2254 + '@esbuild/linux-mips64el': 0.25.12 2255 + '@esbuild/linux-ppc64': 0.25.12 2256 + '@esbuild/linux-riscv64': 0.25.12 2257 + '@esbuild/linux-s390x': 0.25.12 2258 + '@esbuild/linux-x64': 0.25.12 2259 + '@esbuild/netbsd-arm64': 0.25.12 2260 + '@esbuild/netbsd-x64': 0.25.12 2261 + '@esbuild/openbsd-arm64': 0.25.12 2262 + '@esbuild/openbsd-x64': 0.25.12 2263 + '@esbuild/openharmony-arm64': 0.25.12 2264 + '@esbuild/sunos-x64': 0.25.12 2265 + '@esbuild/win32-arm64': 0.25.12 2266 + '@esbuild/win32-ia32': 0.25.12 2267 + '@esbuild/win32-x64': 0.25.12 2268 + 2269 + esbuild@0.27.3: 2270 + optionalDependencies: 2271 + '@esbuild/aix-ppc64': 0.27.3 2272 + '@esbuild/android-arm': 0.27.3 2273 + '@esbuild/android-arm64': 0.27.3 2274 + '@esbuild/android-x64': 0.27.3 2275 + '@esbuild/darwin-arm64': 0.27.3 2276 + '@esbuild/darwin-x64': 0.27.3 2277 + '@esbuild/freebsd-arm64': 0.27.3 2278 + '@esbuild/freebsd-x64': 0.27.3 2279 + '@esbuild/linux-arm': 0.27.3 2280 + '@esbuild/linux-arm64': 0.27.3 2281 + '@esbuild/linux-ia32': 0.27.3 2282 + '@esbuild/linux-loong64': 0.27.3 2283 + '@esbuild/linux-mips64el': 0.27.3 2284 + '@esbuild/linux-ppc64': 0.27.3 2285 + '@esbuild/linux-riscv64': 0.27.3 2286 + '@esbuild/linux-s390x': 0.27.3 2287 + '@esbuild/linux-x64': 0.27.3 2288 + '@esbuild/netbsd-arm64': 0.27.3 2289 + '@esbuild/netbsd-x64': 0.27.3 2290 + '@esbuild/openbsd-arm64': 0.27.3 2291 + '@esbuild/openbsd-x64': 0.27.3 2292 + '@esbuild/openharmony-arm64': 0.27.3 2293 + '@esbuild/sunos-x64': 0.27.3 2294 + '@esbuild/win32-arm64': 0.27.3 2295 + '@esbuild/win32-ia32': 0.27.3 2296 + '@esbuild/win32-x64': 0.27.3 2297 + 2298 + esm-env@1.2.2: {} 2299 + 2300 + esrap@2.2.3: 2301 + dependencies: 2302 + '@jridgewell/sourcemap-codec': 1.5.5 2303 + 2304 + estree-walker@2.0.2: {} 2305 + 2306 + fdir@6.5.0(picomatch@4.0.3): 2307 + optionalDependencies: 2308 + picomatch: 4.0.3 2309 + 2310 + file-uri-to-path@1.0.0: {} 2311 + 2312 + fix-dts-default-cjs-exports@1.0.1: 2313 + dependencies: 2314 + magic-string: 0.30.21 2315 + mlly: 1.8.0 2316 + rollup: 4.59.0 2317 + 2318 + fsevents@2.3.3: 2319 + optional: true 2320 + 304 2321 get-east-asian-width@1.4.0: {} 305 2322 2323 + glob@13.0.6: 2324 + dependencies: 2325 + minimatch: 10.2.4 2326 + minipass: 7.1.3 2327 + path-scurry: 2.0.2 2328 + 2329 + graceful-fs@4.2.11: {} 2330 + 2331 + https-proxy-agent@7.0.6: 2332 + dependencies: 2333 + agent-base: 7.1.4 2334 + debug: 4.4.3 2335 + transitivePeerDependencies: 2336 + - supports-color 2337 + 306 2338 is-fullwidth-code-point@3.0.0: {} 307 2339 308 2340 is-interactive@2.0.0: {} 309 2341 2342 + is-reference@3.0.3: 2343 + dependencies: 2344 + '@types/estree': 1.0.8 2345 + 310 2346 is-unicode-supported@2.1.0: {} 311 2347 312 2348 iso-datestring-validator@2.2.2: {} 313 2349 2350 + jiti@2.6.1: {} 2351 + 2352 + jose@5.10.0: {} 2353 + 2354 + joycon@3.1.1: {} 2355 + 2356 + kleur@4.1.5: {} 2357 + 2358 + lightningcss-android-arm64@1.31.1: 2359 + optional: true 2360 + 2361 + lightningcss-darwin-arm64@1.31.1: 2362 + optional: true 2363 + 2364 + lightningcss-darwin-x64@1.31.1: 2365 + optional: true 2366 + 2367 + lightningcss-freebsd-x64@1.31.1: 2368 + optional: true 2369 + 2370 + lightningcss-linux-arm-gnueabihf@1.31.1: 2371 + optional: true 2372 + 2373 + lightningcss-linux-arm64-gnu@1.31.1: 2374 + optional: true 2375 + 2376 + lightningcss-linux-arm64-musl@1.31.1: 2377 + optional: true 2378 + 2379 + lightningcss-linux-x64-gnu@1.31.1: 2380 + optional: true 2381 + 2382 + lightningcss-linux-x64-musl@1.31.1: 2383 + optional: true 2384 + 2385 + lightningcss-win32-arm64-msvc@1.31.1: 2386 + optional: true 2387 + 2388 + lightningcss-win32-x64-msvc@1.31.1: 2389 + optional: true 2390 + 2391 + lightningcss@1.31.1: 2392 + dependencies: 2393 + detect-libc: 2.1.2 2394 + optionalDependencies: 2395 + lightningcss-android-arm64: 1.31.1 2396 + lightningcss-darwin-arm64: 1.31.1 2397 + lightningcss-darwin-x64: 1.31.1 2398 + lightningcss-freebsd-x64: 1.31.1 2399 + lightningcss-linux-arm-gnueabihf: 1.31.1 2400 + lightningcss-linux-arm64-gnu: 1.31.1 2401 + lightningcss-linux-arm64-musl: 1.31.1 2402 + lightningcss-linux-x64-gnu: 1.31.1 2403 + lightningcss-linux-x64-musl: 1.31.1 2404 + lightningcss-win32-arm64-msvc: 1.31.1 2405 + lightningcss-win32-x64-msvc: 1.31.1 2406 + 2407 + lilconfig@3.1.3: {} 2408 + 2409 + lines-and-columns@1.2.4: {} 2410 + 2411 + load-tsconfig@0.2.5: {} 2412 + 2413 + locate-character@3.0.0: {} 2414 + 314 2415 log-symbols@7.0.1: 315 2416 dependencies: 316 2417 is-unicode-supported: 2.1.0 317 2418 yoctocolors: 2.1.2 318 2419 2420 + lru-cache@10.4.3: {} 2421 + 2422 + lru-cache@11.2.6: {} 2423 + 2424 + magic-string@0.30.21: 2425 + dependencies: 2426 + '@jridgewell/sourcemap-codec': 1.5.5 2427 + 319 2428 mimic-function@5.0.1: {} 320 2429 2430 + minimatch@10.2.4: 2431 + dependencies: 2432 + brace-expansion: 5.0.4 2433 + 2434 + minipass@7.1.3: {} 2435 + 2436 + minizlib@3.1.0: 2437 + dependencies: 2438 + minipass: 7.1.3 2439 + 2440 + mlly@1.8.0: 2441 + dependencies: 2442 + acorn: 8.16.0 2443 + pathe: 2.0.3 2444 + pkg-types: 1.3.1 2445 + ufo: 1.6.3 2446 + 2447 + mri@1.2.0: {} 2448 + 2449 + mrmime@2.0.1: {} 2450 + 2451 + ms@2.1.3: {} 2452 + 321 2453 multiformats@13.4.2: {} 322 2454 323 2455 multiformats@9.9.0: {} 324 2456 2457 + mz@2.7.0: 2458 + dependencies: 2459 + any-promise: 1.3.0 2460 + object-assign: 4.1.1 2461 + thenify-all: 1.6.0 2462 + 2463 + nanoid@3.3.11: {} 2464 + 2465 + node-fetch@2.7.0: 2466 + dependencies: 2467 + whatwg-url: 5.0.0 2468 + 2469 + node-gyp-build@4.8.4: {} 2470 + 2471 + nopt@8.1.0: 2472 + dependencies: 2473 + abbrev: 3.0.1 2474 + 2475 + object-assign@4.1.1: {} 2476 + 2477 + obug@2.1.1: {} 2478 + 325 2479 onetime@7.0.0: 326 2480 dependencies: 327 2481 mimic-function: 5.0.1 ··· 338 2492 string-width: 8.1.0 339 2493 strip-ansi: 7.1.2 340 2494 2495 + path-scurry@2.0.2: 2496 + dependencies: 2497 + lru-cache: 11.2.6 2498 + minipass: 7.1.3 2499 + 2500 + pathe@2.0.3: {} 2501 + 2502 + picocolors@1.1.1: {} 2503 + 2504 + picomatch@4.0.3: {} 2505 + 2506 + pirates@4.0.7: {} 2507 + 2508 + pkg-types@1.3.1: 2509 + dependencies: 2510 + confbox: 0.1.8 2511 + mlly: 1.8.0 2512 + pathe: 2.0.3 2513 + 2514 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8): 2515 + dependencies: 2516 + lilconfig: 3.1.3 2517 + optionalDependencies: 2518 + jiti: 2.6.1 2519 + postcss: 8.5.8 2520 + 2521 + postcss@8.5.8: 2522 + dependencies: 2523 + nanoid: 3.3.11 2524 + picocolors: 1.1.1 2525 + source-map-js: 1.2.1 2526 + 2527 + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7): 2528 + dependencies: 2529 + prettier: 3.8.1 2530 + svelte: 5.53.7 2531 + 2532 + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7))(prettier@3.8.1): 2533 + dependencies: 2534 + prettier: 3.8.1 2535 + optionalDependencies: 2536 + prettier-plugin-svelte: 3.5.1(prettier@3.8.1)(svelte@5.53.7) 2537 + 2538 + prettier@3.8.1: {} 2539 + 2540 + readdirp@4.1.2: {} 2541 + 2542 + resolve-from@5.0.0: {} 2543 + 341 2544 restore-cursor@5.1.0: 342 2545 dependencies: 343 2546 onetime: 7.0.0 344 2547 signal-exit: 4.1.0 345 2548 2549 + rollup@4.59.0: 2550 + dependencies: 2551 + '@types/estree': 1.0.8 2552 + optionalDependencies: 2553 + '@rollup/rollup-android-arm-eabi': 4.59.0 2554 + '@rollup/rollup-android-arm64': 4.59.0 2555 + '@rollup/rollup-darwin-arm64': 4.59.0 2556 + '@rollup/rollup-darwin-x64': 4.59.0 2557 + '@rollup/rollup-freebsd-arm64': 4.59.0 2558 + '@rollup/rollup-freebsd-x64': 4.59.0 2559 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 2560 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 2561 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 2562 + '@rollup/rollup-linux-arm64-musl': 4.59.0 2563 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 2564 + '@rollup/rollup-linux-loong64-musl': 4.59.0 2565 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 2566 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 2567 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 2568 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 2569 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 2570 + '@rollup/rollup-linux-x64-gnu': 4.59.0 2571 + '@rollup/rollup-linux-x64-musl': 4.59.0 2572 + '@rollup/rollup-openbsd-x64': 4.59.0 2573 + '@rollup/rollup-openharmony-arm64': 4.59.0 2574 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 2575 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 2576 + '@rollup/rollup-win32-x64-gnu': 4.59.0 2577 + '@rollup/rollup-win32-x64-msvc': 4.59.0 2578 + fsevents: 2.3.3 2579 + 2580 + sade@1.8.1: 2581 + dependencies: 2582 + mri: 1.2.0 2583 + 2584 + semver@7.7.4: {} 2585 + 2586 + set-cookie-parser@3.0.1: {} 2587 + 346 2588 signal-exit@4.1.0: {} 2589 + 2590 + sirv@3.0.2: 2591 + dependencies: 2592 + '@polka/url': 1.0.0-next.29 2593 + mrmime: 2.0.1 2594 + totalist: 3.0.1 2595 + 2596 + source-map-js@1.2.1: {} 2597 + 2598 + source-map@0.7.6: {} 347 2599 348 2600 stdin-discarder@0.2.2: {} 349 2601 ··· 366 2618 dependencies: 367 2619 ansi-regex: 6.2.2 368 2620 2621 + sucrase@3.35.1: 2622 + dependencies: 2623 + '@jridgewell/gen-mapping': 0.3.13 2624 + commander: 4.1.1 2625 + lines-and-columns: 1.2.4 2626 + mz: 2.7.0 2627 + pirates: 4.0.7 2628 + tinyglobby: 0.2.15 2629 + ts-interface-checker: 0.1.13 2630 + 2631 + svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3): 2632 + dependencies: 2633 + '@jridgewell/trace-mapping': 0.3.31 2634 + chokidar: 4.0.3 2635 + fdir: 6.5.0(picomatch@4.0.3) 2636 + picocolors: 1.1.1 2637 + sade: 1.8.1 2638 + svelte: 5.53.7 2639 + typescript: 5.9.3 2640 + transitivePeerDependencies: 2641 + - picomatch 2642 + 2643 + svelte@5.53.7: 2644 + dependencies: 2645 + '@jridgewell/remapping': 2.3.5 2646 + '@jridgewell/sourcemap-codec': 1.5.5 2647 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) 2648 + '@types/estree': 1.0.8 2649 + '@types/trusted-types': 2.0.7 2650 + acorn: 8.16.0 2651 + aria-query: 5.3.1 2652 + axobject-query: 4.1.0 2653 + clsx: 2.1.1 2654 + devalue: 5.6.3 2655 + esm-env: 1.2.2 2656 + esrap: 2.2.3 2657 + is-reference: 3.0.3 2658 + locate-character: 3.0.0 2659 + magic-string: 0.30.21 2660 + zimmerframe: 1.1.4 2661 + 2662 + tailwindcss@4.2.1: {} 2663 + 2664 + tapable@2.3.0: {} 2665 + 2666 + tar@7.5.9: 2667 + dependencies: 2668 + '@isaacs/fs-minipass': 4.0.1 2669 + chownr: 3.0.0 2670 + minipass: 7.1.3 2671 + minizlib: 3.1.0 2672 + yallist: 5.0.0 2673 + 2674 + thenify-all@1.6.0: 2675 + dependencies: 2676 + thenify: 3.3.1 2677 + 2678 + thenify@3.3.1: 2679 + dependencies: 2680 + any-promise: 1.3.0 2681 + 2682 + tinyexec@0.3.2: {} 2683 + 2684 + tinyglobby@0.2.15: 2685 + dependencies: 2686 + fdir: 6.5.0(picomatch@4.0.3) 2687 + picomatch: 4.0.3 2688 + 369 2689 tlds@1.261.0: {} 370 2690 2691 + totalist@3.0.1: {} 2692 + 2693 + tr46@0.0.3: {} 2694 + 2695 + tree-kill@1.2.2: {} 2696 + 2697 + ts-interface-checker@0.1.13: {} 2698 + 371 2699 tslib@2.8.1: {} 372 2700 2701 + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3): 2702 + dependencies: 2703 + bundle-require: 5.1.0(esbuild@0.27.3) 2704 + cac: 6.7.14 2705 + chokidar: 4.0.3 2706 + consola: 3.4.2 2707 + debug: 4.4.3 2708 + esbuild: 0.27.3 2709 + fix-dts-default-cjs-exports: 1.0.1 2710 + joycon: 3.1.1 2711 + picocolors: 1.1.1 2712 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8) 2713 + resolve-from: 5.0.0 2714 + rollup: 4.59.0 2715 + source-map: 0.7.6 2716 + sucrase: 3.35.1 2717 + tinyexec: 0.3.2 2718 + tinyglobby: 0.2.15 2719 + tree-kill: 1.2.2 2720 + optionalDependencies: 2721 + postcss: 8.5.8 2722 + typescript: 5.9.3 2723 + transitivePeerDependencies: 2724 + - jiti 2725 + - supports-color 2726 + - tsx 2727 + - yaml 2728 + 373 2729 typescript@5.9.3: {} 2730 + 2731 + ufo@1.6.3: {} 374 2732 375 2733 uint8arrays@3.0.0: 376 2734 dependencies: ··· 382 2740 383 2741 varint@6.0.0: {} 384 2742 2743 + vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1): 2744 + dependencies: 2745 + esbuild: 0.27.3 2746 + fdir: 6.5.0(picomatch@4.0.3) 2747 + picomatch: 4.0.3 2748 + postcss: 8.5.8 2749 + rollup: 4.59.0 2750 + tinyglobby: 0.2.15 2751 + optionalDependencies: 2752 + '@types/node': 20.19.27 2753 + fsevents: 2.3.3 2754 + jiti: 2.6.1 2755 + lightningcss: 1.31.1 2756 + 2757 + vitefu@1.1.2(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1)): 2758 + optionalDependencies: 2759 + vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.31.1) 2760 + 2761 + webidl-conversions@3.0.1: {} 2762 + 2763 + whatwg-url@5.0.0: 2764 + dependencies: 2765 + tr46: 0.0.3 2766 + webidl-conversions: 3.0.1 2767 + 2768 + yallist@5.0.0: {} 2769 + 385 2770 yoctocolors@2.1.2: {} 2771 + 2772 + zimmerframe@1.1.4: {} 386 2773 387 2774 zod@3.25.76: {}
+3
pnpm-workspace.yaml
··· 1 + packages: 2 + - 'packages/*' 3 + - 'web'
+54
src/core/auth.ts
··· 1 + /** 2 + * ATProto authentication — environment-agnostic. 3 + * No CLI prompts; credentials come from the caller. 4 + */ 5 + 6 + import { Agent, AtpAgent } from '@atproto/api'; 7 + import { SLINGSHOT_RESOLVER } from './config.js'; 8 + 9 + export interface ResolvedIdentity { 10 + did: string; 11 + handle: string; 12 + pds: string; 13 + } 14 + 15 + /** 16 + * Resolve an AT Protocol handle or DID to its PDS URL via the Slingshot resolver. 17 + */ 18 + export async function resolveIdentity( 19 + identifier: string, 20 + resolverBase = SLINGSHOT_RESOLVER 21 + ): Promise<ResolvedIdentity> { 22 + const url = `${resolverBase}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`; 23 + const res = await fetch(url); 24 + if (!res.ok) { 25 + throw new Error(`Failed to resolve identity: ${res.status} ${res.statusText}`); 26 + } 27 + const data = (await res.json()) as ResolvedIdentity; 28 + if (!data.did || !data.pds) { 29 + throw new Error('Invalid response from identity resolver'); 30 + } 31 + return data; 32 + } 33 + 34 + /** 35 + * Log in to ATProto. 36 + * - If `pdsOverride` is supplied, skips identity resolution and hits that PDS directly. 37 + * - Otherwise uses the Slingshot resolver to find the correct PDS. 38 + */ 39 + export async function login( 40 + identifier: string, 41 + password: string, 42 + pdsOverride?: string 43 + ): Promise<Agent> { 44 + if (pdsOverride) { 45 + const agent = new AtpAgent({ service: pdsOverride }); 46 + await agent.login({ identifier, password }); 47 + return agent; 48 + } 49 + 50 + const identity = await resolveIdentity(identifier); 51 + const agent = new AtpAgent({ service: identity.pds }); 52 + await agent.login({ identifier: identity.did, password }); 53 + return agent; 54 + }
+199
src/core/car-fetch.ts
··· 1 + /** 2 + * CAR export fetcher for ATProto repos — environment-agnostic. 3 + * 4 + * Calls com.atproto.sync.getRepo (sync namespace) which sits on a separate, 5 + * far more generous rate-limit envelope from the AppView. One HTTP request 6 + * downloads the entire repo as a CARv1 file; records are parsed locally. 7 + * 8 + * Dependencies: @ipld/car @ipld/dag-cbor multiformats 9 + */ 10 + 11 + import { CarReader } from '@ipld/car'; 12 + import * as dagCbor from '@ipld/dag-cbor'; 13 + import type { CID } from 'multiformats'; 14 + 15 + // ─── ATProto repo CBOR shapes ───────────────────────────────────────────────── 16 + 17 + interface RepoCommit { 18 + version: number; 19 + did: string; 20 + data: CID; // MST root 21 + rev: string; 22 + sig: Uint8Array; 23 + } 24 + 25 + interface MSTNode { 26 + l: CID | null; 27 + e: Array<{ 28 + p: number; // bytes of previous key to reuse as prefix 29 + k: Uint8Array; // key suffix bytes 30 + v: CID; // record CID 31 + t: CID | null; // right subtree CID 32 + }>; 33 + } 34 + 35 + // ─── helpers ────────────────────────────────────────────────────────────────── 36 + 37 + function cidStr(cid: CID): string { 38 + return cid.toString(); 39 + } 40 + 41 + async function buildBlockMap(reader: CarReader): Promise<Map<string, Uint8Array>> { 42 + const blocks = new Map<string, Uint8Array>(); 43 + for await (const { cid, bytes } of reader.blocks()) { 44 + blocks.set(cidStr(cid), bytes); 45 + } 46 + return blocks; 47 + } 48 + 49 + async function walkMST( 50 + rootCid: CID, 51 + blocks: Map<string, Uint8Array>, 52 + collection: string, 53 + onRecord: (rkey: string, cid: string, value: unknown) => void, 54 + prevKey = '', 55 + ): Promise<string> { 56 + const nodeBytes = blocks.get(cidStr(rootCid)); 57 + if (!nodeBytes) return prevKey; 58 + 59 + const node = dagCbor.decode(nodeBytes) as MSTNode; 60 + let currentKey = prevKey; 61 + 62 + if (node.l) { 63 + currentKey = await walkMST(node.l, blocks, collection, onRecord, currentKey); 64 + } 65 + 66 + for (const entry of node.e ?? []) { 67 + const fullKey = currentKey.slice(0, entry.p) + new TextDecoder().decode(entry.k); 68 + currentKey = fullKey; 69 + 70 + const collPrefix = collection + '/'; 71 + if (fullKey.startsWith(collPrefix)) { 72 + const rkey = fullKey.slice(collPrefix.length); 73 + const valBytes = blocks.get(cidStr(entry.v)); 74 + if (valBytes) { 75 + try { 76 + onRecord(rkey, cidStr(entry.v), dagCbor.decode(valBytes)); 77 + } catch { 78 + // malformed block — skip silently 79 + } 80 + } 81 + } 82 + 83 + if (entry.t) { 84 + currentKey = await walkMST(entry.t, blocks, collection, onRecord, currentKey); 85 + } 86 + } 87 + 88 + return currentKey; 89 + } 90 + 91 + // ─── public API ────────────────────────────────────────────────────────────── 92 + 93 + export interface CARRecord { 94 + rkey: string; 95 + uri: string; 96 + cid: string; 97 + value: unknown; 98 + } 99 + 100 + /** 101 + * Fetch a user's entire ATProto repo as a CAR file and extract all records 102 + * from `collection`. 103 + * 104 + * @param token Optional Bearer token — some PDS instances require auth on 105 + * com.atproto.sync.getRepo even though the spec marks it public. 106 + * @param signal Optional AbortSignal — cancels the download mid-flight. 107 + */ 108 + export async function fetchRepoViaCAR( 109 + pdsUrl: string, 110 + did: string, 111 + collection: string, 112 + signal?: AbortSignal, 113 + token?: string, 114 + ): Promise<CARRecord[]> { 115 + const url = `${pdsUrl.replace(/\/$/, '')}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; 116 + 117 + const headers: Record<string, string> = { Accept: 'application/vnd.ipld.car' }; 118 + if (token) headers['Authorization'] = `Bearer ${token}`; 119 + 120 + const response = await fetch(url, { headers, signal }); 121 + 122 + if (!response.ok) { 123 + throw new Error(`CAR fetch failed: ${response.status} ${response.statusText}`); 124 + } 125 + 126 + const carBytes = new Uint8Array(await response.arrayBuffer()); 127 + const reader = await CarReader.fromBytes(carBytes); 128 + const blocks = await buildBlockMap(reader); 129 + 130 + const [rootCid] = await reader.getRoots(); 131 + if (!rootCid) throw new Error('CAR file has no roots'); 132 + 133 + const commitBytes = blocks.get(cidStr(rootCid)); 134 + if (!commitBytes) throw new Error('Commit block missing from CAR'); 135 + 136 + const commit = dagCbor.decode(commitBytes) as RepoCommit; 137 + if (!commit.data) throw new Error('Commit has no MST root CID'); 138 + 139 + const results: CARRecord[] = []; 140 + await walkMST(commit.data, blocks, collection, (rkey, cid, value) => { 141 + results.push({ rkey, uri: `at://${did}/${collection}/${rkey}`, cid, value }); 142 + }); 143 + 144 + return results; 145 + } 146 + 147 + /** 148 + * Extract the PDS base URL from an @atproto/api Agent or AtpAgent. 149 + * Handles both password-auth agents and OAuth session-manager agents. 150 + */ 151 + export function getPdsUrlFromAgent(agent: unknown): string { 152 + const a = agent as Record<string, unknown>; 153 + 154 + // OAuth agent: session manager carries serverMetadata.issuer as the PDS base URL. 155 + const issuer = (a['sessionManager'] as any)?.serverMetadata?.issuer; 156 + if (issuer) return issuer.toString(); 157 + 158 + // AtpAgent / password-auth agent: direct URL fields. 159 + for (const field of ['dispatchUrl', 'pdsUrl', 'serviceUrl', 'service']) { 160 + const v = a[field] ?? (a['sessionManager'] as any)?.[field]; 161 + if (v) return v.toString(); 162 + } 163 + 164 + throw new Error('Cannot determine PDS URL from agent'); 165 + } 166 + 167 + /** 168 + * Extract a Bearer token from an agent for authenticated CAR fetches. 169 + * 170 + * Some PDS instances return 401 on com.atproto.sync.getRepo without auth, 171 + * even though the spec marks it public. This helper covers both auth shapes: 172 + * 173 + * - Password / AtpAgent: agent.session.accessJwt 174 + * - OAuth (browser): agent.sessionManager.getTokens() → accessToken 175 + * 176 + * Returns undefined if the token can't be obtained non-destructively 177 + * (e.g. an expired OAuth session that would need a refresh — callers should 178 + * let the normal agent.* API methods handle that path instead). 179 + */ 180 + export async function getAgentToken(agent: unknown): Promise<string | undefined> { 181 + const a = agent as Record<string, unknown>; 182 + 183 + // Password-auth AtpAgent: session carries a plain JWT. 184 + const jwt = (a['session'] as any)?.accessJwt; 185 + if (jwt) return jwt as string; 186 + 187 + // OAuth agent: session manager exposes getTokens() (non-mutating read). 188 + const sm = (a['sessionManager'] as any); 189 + if (typeof sm?.getTokens === 'function') { 190 + try { 191 + const tokens = await sm.getTokens() as { accessToken?: string } | null; 192 + if (tokens?.accessToken) return tokens.accessToken; 193 + } catch { 194 + // If the OAuth token is expired and can't be refreshed silently, fall through. 195 + } 196 + } 197 + 198 + return undefined; 199 + }
+11
src/core/config.ts
··· 1 + /** 2 + * Shared constants — environment-agnostic. 3 + * No Node.js dependencies; safe for both CLI and browser. 4 + */ 5 + 6 + export const RECORD_TYPE = 'fm.teal.alpha.feed.play'; 7 + export const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue'; 8 + export const MAX_PDS_BATCH_SIZE = 200; 9 + export const POINTS_PER_RECORD = 3; 10 + // Single source of truth for the version string — keep in sync with package.json. 11 + export const VERSION = '0.10.0';
+144
src/core/csv.ts
··· 1 + /** 2 + * Last.fm CSV parsing — environment-agnostic. 3 + * No Node.js deps; file I/O is the caller's responsibility. 4 + */ 5 + 6 + import type { LastFmCsvRecord, PlayRecord } from './types.js'; 7 + import { RECORD_TYPE } from './config.js'; 8 + 9 + // ─── delimiter detection ────────────────────────────────────────────────────── 10 + 11 + function detectDelimiter(content: string): string { 12 + const firstLine = content.split('\n')[0]; 13 + const delimiters = [',', ';', '\t', '|']; 14 + let maxCount = 0; 15 + let best = ','; 16 + for (const d of delimiters) { 17 + const count = firstLine.split(d).length; 18 + if (count > maxCount) { maxCount = count; best = d; } 19 + } 20 + return best; 21 + } 22 + 23 + // ─── column normalisation ───────────────────────────────────────────────────── 24 + 25 + const COLUMN_MAP: Record<string, string> = { 26 + uts: 'uts', date: 'uts', timestamp: 'uts', played_at: 'uts', time: 'uts', 27 + artist: 'artist', artist_name: 'artist', artistname: 'artist', 28 + artist_mbid: 'artist_mbid', artistmbid: 'artist_mbid', artist_id: 'artist_mbid', 29 + album: 'album', album_name: 'album', albumname: 'album', release: 'album', 30 + album_mbid: 'album_mbid', albummbid: 'album_mbid', albumid: 'album_mbid', album_id: 'album_mbid', 31 + track: 'track', track_name: 'track', trackname: 'track', song: 'track', title: 'track', 32 + track_mbid: 'track_mbid', trackmbid: 'track_mbid', track_id: 'track_mbid', 33 + utc_time: 'utc_time', utctime: 'utc_time', datetime: 'utc_time', 34 + }; 35 + 36 + function normalizeRecord(raw: Record<string, string>): LastFmCsvRecord { 37 + const normalized: Record<string, string> = {}; 38 + for (const [k, v] of Object.entries(raw)) { 39 + const mapped = COLUMN_MAP[k.toLowerCase()]; 40 + if (mapped) normalized[mapped] = v; 41 + } 42 + if (normalized['uts']) { 43 + const ts = normalized['uts'].toString(); 44 + if (ts.length >= 13) normalized['uts'] = Math.floor(parseInt(ts) / 1000).toString(); 45 + } 46 + if (normalized['uts'] && !normalized['utc_time']) { 47 + normalized['utc_time'] = new Date(parseInt(normalized['uts']) * 1000).toISOString(); 48 + } 49 + return normalized as unknown as LastFmCsvRecord; 50 + } 51 + 52 + // ─── minimal CSV parser ─────────────────────────────────────────────────────── 53 + 54 + function parseCSV(content: string, delimiter: string): Record<string, string>[] { 55 + const lines = content.split(/\r?\n/).filter((l) => l.trim()); 56 + if (lines.length < 2) return []; 57 + 58 + const parseRow = (line: string): string[] => { 59 + const cells: string[] = []; 60 + let cur = ''; 61 + let inQuote = false; 62 + for (let i = 0; i < line.length; i++) { 63 + const ch = line[i]; 64 + if (ch === '"') { 65 + if (inQuote && line[i + 1] === '"') { cur += '"'; i++; } 66 + else inQuote = !inQuote; 67 + } else if (ch === delimiter && !inQuote) { 68 + cells.push(cur.trim()); cur = ''; 69 + } else { 70 + cur += ch; 71 + } 72 + } 73 + cells.push(cur.trim()); 74 + return cells; 75 + }; 76 + 77 + const headers = parseRow(lines[0]); 78 + const records: Record<string, string>[] = []; 79 + for (let i = 1; i < lines.length; i++) { 80 + const cells = parseRow(lines[i]); 81 + const record: Record<string, string> = {}; 82 + headers.forEach((h, idx) => { record[h] = cells[idx] ?? ''; }); 83 + records.push(record); 84 + } 85 + return records; 86 + } 87 + 88 + // ─── public API ─────────────────────────────────────────────────────────────── 89 + 90 + /** 91 + * Parse a raw Last.fm CSV string into normalised records. 92 + * Handles BOM, username comments in the header, and delimiter auto-detection. 93 + */ 94 + export function parseLastFmCsvContent(rawContent: string): LastFmCsvRecord[] { 95 + let content = rawContent; 96 + // Strip BOM 97 + if (content.charCodeAt(0) === 0xfeff) content = content.slice(1); 98 + // Strip username comment from header line 99 + const lines = content.split('\n'); 100 + lines[0] = lines[0].split('#')[0].trim(); 101 + content = lines.join('\n'); 102 + 103 + const delimiter = detectDelimiter(content); 104 + const raw = parseCSV(content, delimiter); 105 + const records = raw.map(normalizeRecord); 106 + return records.filter((r) => r.artist && r.track && r.uts); 107 + } 108 + 109 + /** 110 + * Convert a normalised Last.fm CSV record to an ATProto play record. 111 + * 112 + * @param clientAgent The `submissionClientAgent` string for this runtime 113 + * (e.g. `malachite/v0.10.0` for CLI, `malachite/v0.3.0 (web)` for web). 114 + */ 115 + export function convertToPlayRecord(csv: LastFmCsvRecord, clientAgent: string): PlayRecord { 116 + const playedTime = new Date(parseInt(csv.uts) * 1000).toISOString(); 117 + 118 + const artists: PlayRecord['artists'] = []; 119 + if (csv.artist) { 120 + const a: PlayRecord['artists'][0] = { artistName: csv.artist }; 121 + if (csv.artist_mbid?.trim()) a.artistMbId = csv.artist_mbid; 122 + artists.push(a); 123 + } 124 + 125 + const record: PlayRecord = { 126 + $type: RECORD_TYPE, 127 + trackName: csv.track, 128 + artists, 129 + playedTime, 130 + submissionClientAgent: clientAgent, 131 + musicServiceBaseDomain: 'last.fm', 132 + originUrl: '', 133 + }; 134 + 135 + if (csv.album?.trim()) record.releaseName = csv.album; 136 + if (csv.album_mbid?.trim()) record.releaseMbId = csv.album_mbid; 137 + if (csv.track_mbid?.trim()) record.recordingMbId = csv.track_mbid; 138 + 139 + const aEnc = encodeURIComponent(csv.artist); 140 + const tEnc = encodeURIComponent(csv.track); 141 + record.originUrl = `https://www.last.fm/music/${aEnc}/_/${tEnc}`; 142 + 143 + return record; 144 + }
+131
src/core/merge.ts
··· 1 + /** 2 + * Record merge / deduplication helpers — environment-agnostic. 3 + */ 4 + 5 + import type { PlayRecord } from './types.js'; 6 + 7 + // ─── internal helpers ───────────────────────────────────────────────────────── 8 + 9 + function normalizeString(s: string): string { 10 + return s.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); 11 + } 12 + 13 + interface NormalizedRecord { 14 + original: PlayRecord; 15 + normalizedTrack: string; 16 + normalizedArtist: string; 17 + timestamp: number; 18 + source: 'lastfm' | 'spotify'; 19 + } 20 + 21 + function toNorm(r: PlayRecord, source: 'lastfm' | 'spotify'): NormalizedRecord { 22 + return { 23 + original: r, 24 + normalizedTrack: normalizeString(r.trackName), 25 + normalizedArtist: normalizeString(r.artists[0]?.artistName ?? ''), 26 + timestamp: new Date(r.playedTime).getTime(), 27 + source, 28 + }; 29 + } 30 + 31 + function areDuplicates(a: NormalizedRecord, b: NormalizedRecord): boolean { 32 + return ( 33 + Math.abs(a.timestamp - b.timestamp) <= 300_000 && 34 + a.normalizedTrack === b.normalizedTrack && 35 + a.normalizedArtist === b.normalizedArtist 36 + ); 37 + } 38 + 39 + function betterRecord(a: NormalizedRecord, b: NormalizedRecord): PlayRecord { 40 + const hasMb = (n: NormalizedRecord) => 41 + n.source === 'lastfm' && 42 + (n.original.recordingMbId || n.original.releaseMbId || n.original.artists[0]?.artistMbId); 43 + if (hasMb(a) && !hasMb(b)) return a.original; 44 + if (hasMb(b) && !hasMb(a)) return b.original; 45 + return a.source === 'spotify' ? a.original : b.original; 46 + } 47 + 48 + // ─── public API ─────────────────────────────────────────────────────────────── 49 + 50 + export interface MergeStats { 51 + lastfmTotal: number; 52 + spotifyTotal: number; 53 + duplicatesRemoved: number; 54 + mergedTotal: number; 55 + } 56 + 57 + /** 58 + * Merge Last.fm and Spotify exports, deduplicating records within ±5 minutes 59 + * of each other. Prefers Last.fm records that carry MusicBrainz IDs, otherwise 60 + * prefers Spotify for its richer metadata. 61 + */ 62 + export function mergePlayRecords( 63 + lastfmRecords: PlayRecord[], 64 + spotifyRecords: PlayRecord[] 65 + ): { merged: PlayRecord[]; stats: MergeStats } { 66 + const all = [ 67 + ...lastfmRecords.map((r) => toNorm(r, 'lastfm')), 68 + ...spotifyRecords.map((r) => toNorm(r, 'spotify')), 69 + ].sort((a, b) => a.timestamp - b.timestamp); 70 + 71 + const unique: PlayRecord[] = []; 72 + const seen = new Set<string>(); 73 + let dups = 0; 74 + 75 + for (const rec of all) { 76 + const key = `${rec.normalizedTrack}|${rec.normalizedArtist}|${Math.floor(rec.timestamp / 60_000)}`; 77 + if (seen.has(key)) { 78 + const idx = unique.findIndex((u) => { 79 + const n = toNorm(u, u.musicServiceBaseDomain === 'last.fm' ? 'lastfm' : 'spotify'); 80 + return areDuplicates(rec, n); 81 + }); 82 + if (idx !== -1) { 83 + const existing = toNorm(unique[idx], unique[idx].musicServiceBaseDomain === 'last.fm' ? 'lastfm' : 'spotify'); 84 + unique[idx] = betterRecord(existing, rec); 85 + dups++; 86 + continue; 87 + } 88 + } 89 + seen.add(key); 90 + unique.push(rec.original); 91 + } 92 + 93 + unique.sort((a, b) => new Date(a.playedTime).getTime() - new Date(b.playedTime).getTime()); 94 + 95 + return { 96 + merged: unique, 97 + stats: { 98 + lastfmTotal: lastfmRecords.length, 99 + spotifyTotal: spotifyRecords.length, 100 + duplicatesRemoved: dups, 101 + mergedTotal: unique.length, 102 + }, 103 + }; 104 + } 105 + 106 + /** 107 + * Remove duplicate records within a single input set, keeping the first 108 + * occurrence of each (artist, track, timestamp) triple. 109 + */ 110 + export function deduplicateInputRecords( 111 + records: PlayRecord[] 112 + ): { unique: PlayRecord[]; duplicates: number } { 113 + const seen = new Map<string, PlayRecord>(); 114 + let dups = 0; 115 + for (const r of records) { 116 + const key = `${(r.artists[0]?.artistName ?? '').toLowerCase()}|||${r.trackName.toLowerCase()}|||${r.playedTime}`; 117 + if (!seen.has(key)) seen.set(key, r); 118 + else dups++; 119 + } 120 + return { unique: Array.from(seen.values()), duplicates: dups }; 121 + } 122 + 123 + /** 124 + * Sort records chronologically (oldest first by default). 125 + */ 126 + export function sortRecords(records: PlayRecord[], reverseChronological = false): PlayRecord[] { 127 + return [...records].sort((a, b) => { 128 + const diff = new Date(a.playedTime).getTime() - new Date(b.playedTime).getTime(); 129 + return reverseChronological ? -diff : diff; 130 + }); 131 + }
+212
src/core/publisher.ts
··· 1 + /** 2 + * ATProto record publisher — environment-agnostic. 3 + * All progress/logging is surfaced via callbacks; no console.log or UI deps. 4 + * The CLI wrapper in src/lib/publisher.ts adapts this to terminal UI. 5 + */ 6 + 7 + import type { Agent } from '@atproto/api'; 8 + import type { PlayRecord } from './types.js'; 9 + import { RECORD_TYPE, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from './config.js'; 10 + import { RateLimiter } from './rate-limiter.js'; 11 + import { generateTIDFromISO } from './tid.js'; 12 + import { normalizeHeaders, isRateLimitError } from './rate-limit-headers.js'; 13 + 14 + export interface PublishProgress { 15 + batchIndex: number; 16 + totalBatches: number; 17 + recordsProcessed: number; 18 + totalRecords: number; 19 + successCount: number; 20 + errorCount: number; 21 + currentBatchSize: number; 22 + message: string; 23 + } 24 + 25 + export interface PublisherCallbacks { 26 + onProgress: (p: PublishProgress) => void; 27 + onLog: (level: 'info' | 'success' | 'warn' | 'error' | 'progress', msg: string) => void; 28 + isCancelled: () => boolean; 29 + } 30 + 31 + function cancellableSleep(ms: number, isCancelled: () => boolean): Promise<void> { 32 + return new Promise((resolve) => { 33 + const end = Date.now() + ms; 34 + const tick = () => { 35 + if (isCancelled() || Date.now() >= end) { resolve(); return; } 36 + setTimeout(tick, Math.min(50, end - Date.now())); 37 + }; 38 + tick(); 39 + }); 40 + } 41 + 42 + function extractHeaders(response: unknown): Record<string, string> { 43 + const headers: Record<string, string> = {}; 44 + const r = response as any; 45 + if (r?.headers) { 46 + if (typeof r.headers.forEach === 'function') { 47 + r.headers.forEach((v: string, k: string) => { headers[k] = v; }); 48 + } else { 49 + Object.assign(headers, r.headers); 50 + } 51 + } 52 + return headers; 53 + } 54 + 55 + /** Simple retry wrapper for transient network failures. */ 56 + async function withRetry<T>( 57 + fn: () => Promise<T>, 58 + maxAttempts = 3, 59 + onRetry?: (attempt: number, err: Error) => void 60 + ): Promise<T> { 61 + let lastErr!: Error; 62 + for (let attempt = 1; attempt <= maxAttempts; attempt++) { 63 + try { 64 + return await fn(); 65 + } catch (err: unknown) { 66 + lastErr = err as Error; 67 + const isTransient = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|network|timeout|503|502|504/i.test(lastErr.message ?? ''); 68 + if (!isTransient || attempt === maxAttempts) throw lastErr; 69 + onRetry?.(attempt, lastErr); 70 + await new Promise((r) => setTimeout(r, 1000 * attempt)); 71 + } 72 + } 73 + throw lastErr; 74 + } 75 + 76 + export async function publishRecords( 77 + agent: Agent, 78 + records: PlayRecord[], 79 + dryRun: boolean, 80 + callbacks: PublisherCallbacks, 81 + context = 'publish' 82 + ): Promise<{ successCount: number; errorCount: number; cancelled: boolean }> { 83 + const { onProgress, onLog, isCancelled } = callbacks; 84 + const total = records.length; 85 + 86 + if (dryRun) { 87 + onLog('info', `[DRY RUN] Would publish ${total.toLocaleString()} records`); 88 + records.slice(0, 5).forEach((r, i) => { 89 + onLog('info', ` ${i + 1}. ${r.artists[0]?.artistName} – ${r.trackName} (${r.playedTime.slice(0, 10)})`); 90 + }); 91 + if (total > 5) onLog('info', ` …and ${total - 5} more`); 92 + return { successCount: total, errorCount: 0, cancelled: false }; 93 + } 94 + 95 + // Abort controller so in-flight fetches are cancelled immediately on stop. 96 + const ac = new AbortController(); 97 + const cancelPoll = setInterval(() => { if (isCancelled()) ac.abort(); }, 50); 98 + 99 + const rl = new RateLimiter({ headroom: 0.15 }); 100 + let currentBatchSize = 50; // probe batch 101 + let currentDelay = 500; 102 + let successCount = 0; 103 + let errorCount = 0; 104 + let batchCounter = 0; 105 + let i = 0; 106 + const startTime = Date.now(); 107 + 108 + onLog('info', `Publishing ${total.toLocaleString()} records to ATProto…`); 109 + 110 + try { 111 + while (i < total) { 112 + if (isCancelled()) { 113 + onLog('warn', 'Import cancelled by user.'); 114 + return { successCount, errorCount, cancelled: true }; 115 + } 116 + 117 + const batch = records.slice(i, Math.min(i + currentBatchSize, total)); 118 + batchCounter++; 119 + const pct = ((i / total) * 100).toFixed(1); 120 + 121 + onProgress({ 122 + batchIndex: batchCounter, 123 + totalBatches: Math.ceil(total / currentBatchSize), 124 + recordsProcessed: i, 125 + totalRecords: total, 126 + successCount, 127 + errorCount, 128 + currentBatchSize: batch.length, 129 + message: `[${pct}%] Batch ${batchCounter} — records ${i + 1}–${Math.min(i + batch.length, total)}`, 130 + }); 131 + 132 + const writes = await Promise.all( 133 + batch.map(async (record) => ({ 134 + $type: 'com.atproto.repo.applyWrites#create', 135 + collection: RECORD_TYPE, 136 + rkey: await generateTIDFromISO(record.playedTime, context), 137 + value: record, 138 + })) 139 + ); 140 + 141 + const batchPoints = batch.length * POINTS_PER_RECORD; 142 + await rl.waitForPermit(batchPoints, isCancelled); 143 + if (isCancelled()) { 144 + onLog('warn', 'Import cancelled by user.'); 145 + return { successCount, errorCount, cancelled: true }; 146 + } 147 + 148 + try { 149 + const response = await withRetry( 150 + () => agent.com.atproto.repo.applyWrites( 151 + { repo: agent.did ?? '', writes: writes as any }, 152 + { signal: ac.signal } 153 + ), 154 + 3, 155 + (attempt, err) => onLog('warn', `⚠️ Batch ${batchCounter} failed (attempt ${attempt}/3): ${err.message} — retrying…`) 156 + ); 157 + 158 + successCount += (response.data as any).results?.length ?? batch.length; 159 + 160 + // Learn rate limits from response headers 161 + const rawHeaders = extractHeaders(response); 162 + if (Object.keys(rawHeaders).length > 0) { 163 + const norm = normalizeHeaders(rawHeaders); 164 + rl.updateFromHeaders(norm); 165 + 166 + if (batchCounter === 1) { 167 + const cap = rl.getServerCapacity(); 168 + if (cap) { 169 + const remaining = rl.getActualRemaining(); 170 + const recsPerSec = (cap.limit / cap.windowSeconds / POINTS_PER_RECORD) * 0.8; 171 + currentBatchSize = Math.min(MAX_PDS_BATCH_SIZE, Math.max(10, Math.floor(recsPerSec * 45))); 172 + currentDelay = Math.max(500, Math.floor((currentBatchSize / recsPerSec) * 1000)); 173 + onLog('info', `📊 Server: ${cap.limit} pts/${cap.windowSeconds}s — optimised to ${currentBatchSize} records/batch`); 174 + onLog('info', ` Remaining quota: ${remaining.toLocaleString()}/${cap.limit.toLocaleString()}`); 175 + } 176 + } 177 + } 178 + 179 + const rps = (successCount / ((Date.now() - startTime) / 1000)).toFixed(1); 180 + onLog('progress', `✓ Batch ${batchCounter} — ${successCount}/${total} records (${rps} rec/s)`); 181 + i += batch.length; 182 + 183 + } catch (err: unknown) { 184 + const e = err as any; 185 + if (ac.signal.aborted || isCancelled()) { 186 + return { successCount, errorCount, cancelled: true }; 187 + } 188 + if (isRateLimitError(e)) { 189 + onLog('warn', '⚠️ Rate limit hit — waiting for quota reset…'); 190 + const rawErrHeaders: Record<string, string> = 191 + typeof e?.response?.headers?.forEach === 'function' 192 + ? (() => { const o: Record<string, string> = {}; e.response.headers.forEach((v: string, k: string) => { o[k] = v; }); return o; })() 193 + : (e?.response?.headers ?? e?.headers ?? {}); 194 + rl.handleRateLimitHit(normalizeHeaders(rawErrHeaders)); 195 + await rl.waitForPermit(batchPoints, isCancelled); 196 + continue; // retry same batch 197 + } 198 + errorCount += batch.length; 199 + onLog('error', `✗ Batch ${batchCounter} failed: ${e?.message ?? e}`); 200 + i += batch.length; 201 + } 202 + 203 + if (i < total) { 204 + await cancellableSleep(currentDelay, isCancelled); 205 + } 206 + } 207 + } finally { 208 + clearInterval(cancelPoll); 209 + } 210 + 211 + return { successCount, errorCount, cancelled: false }; 212 + }
+48
src/core/rate-limit-headers.ts
··· 1 + /** 2 + * Rate-limit header parsing — environment-agnostic. 3 + * No logger dependency; callers surface messages as they see fit. 4 + */ 5 + 6 + export interface RateLimitHeaders { 7 + limit?: number; 8 + remaining?: number; 9 + reset?: number; 10 + windowSeconds?: number; 11 + } 12 + 13 + export function normalizeHeaders(headers: Record<string, string>): Record<string, string> { 14 + const out: Record<string, string> = {}; 15 + for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; 16 + return out; 17 + } 18 + 19 + export function parseRateLimitHeaders(headers: Record<string, string>): RateLimitHeaders { 20 + const h = normalizeHeaders(headers); 21 + const get = (k: string) => h[k] ?? h[`x-${k}`] ?? ''; 22 + 23 + const limit = parseInt(get('ratelimit-limit'), 10); 24 + const remaining = parseInt(get('ratelimit-remaining'), 10); 25 + const reset = parseInt(get('ratelimit-reset'), 10); 26 + const policy = get('ratelimit-policy'); 27 + 28 + let windowSeconds: number | undefined; 29 + const m = /;w=(\d+)/.exec(policy); 30 + if (m) windowSeconds = parseInt(m[1], 10); 31 + else if (!isNaN(reset)) { 32 + windowSeconds = Math.max(0, reset - Math.floor(Date.now() / 1000)); 33 + } 34 + 35 + return { 36 + limit: isNaN(limit) ? undefined : limit, 37 + remaining: isNaN(remaining) ? undefined : remaining, 38 + reset: isNaN(reset) ? undefined : reset, 39 + windowSeconds, 40 + }; 41 + } 42 + 43 + export function isRateLimitError(err: unknown): boolean { 44 + const e = err as any; 45 + if (e?.status === 429) return true; 46 + const msg = (e?.message ?? '').toLowerCase(); 47 + return msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('ratelimit'); 48 + }
+112
src/core/rate-limiter.ts
··· 1 + /** 2 + * In-memory rate limiter — environment-agnostic. 3 + * Tracks quota from response headers and gates requests to stay within limits. 4 + */ 5 + 6 + import { normalizeHeaders } from './rate-limit-headers.js'; 7 + 8 + interface State { 9 + limit: number; 10 + remaining: number; 11 + resetAt: number; // unix seconds 12 + windowSeconds: number; 13 + } 14 + 15 + export class RateLimiter { 16 + private state: State | null = null; 17 + private readonly headroom: number; 18 + 19 + constructor(opts?: { headroom?: number }) { 20 + this.headroom = opts?.headroom ?? 0.15; 21 + } 22 + 23 + updateFromHeaders(headers: Record<string, string>): void { 24 + const h = normalizeHeaders(headers); 25 + const get = (k: string) => h[k] ?? h[`x-${k}`] ?? ''; 26 + 27 + const limit = parseInt(get('ratelimit-limit'), 10); 28 + const remaining = parseInt(get('ratelimit-remaining'), 10); 29 + const reset = parseInt(get('ratelimit-reset'), 10); 30 + const policy = get('ratelimit-policy'); 31 + 32 + if (!limit || isNaN(limit) || isNaN(remaining)) return; 33 + 34 + let windowSeconds = 3600; 35 + const m = /;w=(\d+)/.exec(policy); 36 + if (m) windowSeconds = parseInt(m[1], 10); 37 + 38 + const now = Math.floor(Date.now() / 1000); 39 + this.state = { 40 + limit, 41 + remaining, 42 + resetAt: isNaN(reset) ? now + windowSeconds : reset, 43 + windowSeconds, 44 + }; 45 + } 46 + 47 + getActualRemaining(): number { 48 + if (!this.state) return 0; 49 + if (Math.floor(Date.now() / 1000) >= this.state.resetAt) return this.state.limit; 50 + return this.state.remaining; 51 + } 52 + 53 + getServerCapacity(): { limit: number; windowSeconds: number } | null { 54 + if (!this.state || this.state.limit === 0) return null; 55 + return { limit: this.state.limit, windowSeconds: this.state.windowSeconds }; 56 + } 57 + 58 + hasServerInfo(): boolean { 59 + return this.state !== null && this.state.limit > 0; 60 + } 61 + 62 + /** 63 + * Called when the server returns a 429. Zeroes remaining so the next 64 + * `waitForPermit` actually blocks until the window resets. 65 + */ 66 + handleRateLimitHit(errHeaders?: Record<string, string>): void { 67 + if (errHeaders && Object.keys(errHeaders).length > 0) { 68 + this.updateFromHeaders(errHeaders); 69 + } 70 + const now = Math.floor(Date.now() / 1000); 71 + if (this.state) { 72 + this.state.remaining = 0; 73 + } else { 74 + this.state = { limit: 5000, remaining: 0, resetAt: now + 60, windowSeconds: 3600 }; 75 + } 76 + } 77 + 78 + /** 79 + * Wait until there is sufficient quota to send `pointsNeeded` points. 80 + * Polls `isCancelled` every 50 ms so callers can abort mid-wait. 81 + */ 82 + async waitForPermit(pointsNeeded: number, isCancelled?: () => boolean): Promise<void> { 83 + if (!this.state) return; // no info yet — let first request probe 84 + 85 + const now = Math.floor(Date.now() / 1000); 86 + if (now >= this.state.resetAt) { 87 + this.state.remaining = this.state.limit; 88 + this.state.resetAt = now + this.state.windowSeconds; 89 + } 90 + 91 + const headroomPts = Math.floor(this.state.limit * this.headroom); 92 + const effective = this.state.remaining - headroomPts; 93 + 94 + if (effective < pointsNeeded) { 95 + const resetMs = Math.max(0, (this.state.resetAt - Math.floor(Date.now() / 1000)) + 1) * 1000; 96 + const end = Date.now() + resetMs; 97 + await new Promise<void>((resolve) => { 98 + const tick = () => { 99 + if (isCancelled?.() || Date.now() >= end) { resolve(); return; } 100 + setTimeout(tick, Math.min(50, end - Date.now())); 101 + }; 102 + tick(); 103 + }); 104 + if (this.state && !isCancelled?.()) { 105 + this.state.remaining = this.state.limit; 106 + this.state.resetAt = Math.floor(Date.now() / 1000) + this.state.windowSeconds; 107 + } 108 + } 109 + 110 + if (this.state) this.state.remaining = Math.max(0, this.state.remaining - pointsNeeded); 111 + } 112 + }
+48
src/core/spotify.ts
··· 1 + /** 2 + * Spotify JSON parsing — environment-agnostic. 3 + * No Node.js deps; file I/O is the caller's responsibility. 4 + */ 5 + 6 + import type { SpotifyRecord, PlayRecord } from './types.js'; 7 + import { RECORD_TYPE } from './config.js'; 8 + 9 + export type { SpotifyRecord }; 10 + 11 + /** 12 + * Filter raw Spotify records, keeping only music tracks (not podcasts). 13 + */ 14 + export function parseSpotifyJsonContent(records: SpotifyRecord[]): SpotifyRecord[] { 15 + return records.filter( 16 + (r) => r.master_metadata_track_name && r.master_metadata_album_artist_name 17 + ); 18 + } 19 + 20 + /** 21 + * Convert a Spotify record to an ATProto play record. 22 + * 23 + * @param clientAgent The `submissionClientAgent` string for this runtime. 24 + */ 25 + export function convertSpotifyToPlayRecord(r: SpotifyRecord, clientAgent: string): PlayRecord { 26 + const artists: PlayRecord['artists'] = []; 27 + if (r.master_metadata_album_artist_name) { 28 + artists.push({ artistName: r.master_metadata_album_artist_name }); 29 + } 30 + 31 + const record: PlayRecord = { 32 + $type: RECORD_TYPE, 33 + trackName: r.master_metadata_track_name ?? 'Unknown Track', 34 + artists, 35 + playedTime: r.ts, 36 + submissionClientAgent: clientAgent, 37 + musicServiceBaseDomain: 'spotify.com', 38 + originUrl: '', 39 + }; 40 + 41 + if (r.master_metadata_album_album_name) record.releaseName = r.master_metadata_album_album_name; 42 + if (r.spotify_track_uri) { 43 + const id = r.spotify_track_uri.replace('spotify:track:', ''); 44 + record.originUrl = `https://open.spotify.com/track/${id}`; 45 + } 46 + 47 + return record; 48 + }
+133
src/core/sync.ts
··· 1 + /** 2 + * Sync helpers — environment-agnostic. 3 + * Fetches existing records via CAR export and provides filter / dedup logic. 4 + * No CLI UI or caching; those are added by the CLI wrapper in src/lib/sync.ts. 5 + */ 6 + 7 + import type { Agent } from '@atproto/api'; 8 + import type { PlayRecord } from './types.js'; 9 + import { RECORD_TYPE } from './config.js'; 10 + import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken } from './car-fetch.js'; 11 + 12 + export interface ExistingRecord { 13 + uri: string; 14 + cid: string; 15 + value: PlayRecord; 16 + } 17 + 18 + export interface DedupGroup { 19 + key: string; 20 + records: ExistingRecord[]; 21 + } 22 + 23 + export function recordKey(r: PlayRecord): string { 24 + const artist = (r.artists[0]?.artistName ?? '').toLowerCase().trim(); 25 + return `${artist}|||${r.trackName.toLowerCase().trim()}|||${r.playedTime}`; 26 + } 27 + 28 + /** In-session memory cache — avoids re-fetching within the same process/page. */ 29 + const sessionCache = new Map<string, Map<string, ExistingRecord>>(); 30 + 31 + export async function fetchExistingRecords( 32 + agent: Agent, 33 + onProgress?: (fetched: number) => void, 34 + forceRefresh = false, 35 + signal?: AbortSignal 36 + ): Promise<Map<string, ExistingRecord>> { 37 + const did = agent.did; 38 + if (!did) throw new Error('No authenticated session'); 39 + 40 + if (!forceRefresh && sessionCache.has(did)) { 41 + return sessionCache.get(did)!; 42 + } 43 + 44 + signal?.throwIfAborted(); 45 + 46 + const pdsUrl = getPdsUrlFromAgent(agent); 47 + const token = await getAgentToken(agent); 48 + const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, token); 49 + 50 + const map = new Map<string, ExistingRecord>(); 51 + for (const rec of carRecords) { 52 + const value = rec.value as unknown as PlayRecord; 53 + map.set(recordKey(value), { uri: rec.uri, cid: rec.cid, value }); 54 + } 55 + 56 + onProgress?.(map.size); 57 + sessionCache.set(did, map); 58 + return map; 59 + } 60 + 61 + export function filterNewRecords( 62 + records: PlayRecord[], 63 + existing: Map<string, ExistingRecord> 64 + ): PlayRecord[] { 65 + return records.filter((r) => !existing.has(recordKey(r))); 66 + } 67 + 68 + export async function fetchAllRecordsForDedup( 69 + agent: Agent, 70 + onProgress?: (fetched: number) => void, 71 + signal?: AbortSignal 72 + ): Promise<ExistingRecord[]> { 73 + const did = agent.did; 74 + if (!did) throw new Error('No authenticated session'); 75 + 76 + signal?.throwIfAborted(); 77 + 78 + const pdsUrl = getPdsUrlFromAgent(agent); 79 + const token = await getAgentToken(agent); 80 + const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, token); 81 + 82 + const all: ExistingRecord[] = carRecords.map((rec) => ({ 83 + uri: rec.uri, 84 + cid: rec.cid, 85 + value: rec.value as unknown as PlayRecord, 86 + })); 87 + 88 + onProgress?.(all.length); 89 + return all; 90 + } 91 + 92 + export function findDuplicateGroups(records: ExistingRecord[]): DedupGroup[] { 93 + const groups = new Map<string, ExistingRecord[]>(); 94 + for (const rec of records) { 95 + const key = recordKey(rec.value); 96 + if (!groups.has(key)) groups.set(key, []); 97 + groups.get(key)!.push(rec); 98 + } 99 + const result: DedupGroup[] = []; 100 + for (const [key, recs] of groups) { 101 + if (recs.length > 1) result.push({ key, records: recs }); 102 + } 103 + return result; 104 + } 105 + 106 + export async function removeDuplicateRecords( 107 + agent: Agent, 108 + groups: DedupGroup[], 109 + onProgress?: (removed: number) => void, 110 + signal?: AbortSignal 111 + ): Promise<number> { 112 + let removed = 0; 113 + for (const group of groups) { 114 + for (const rec of group.records.slice(1)) { 115 + signal?.throwIfAborted(); 116 + try { 117 + await agent.com.atproto.repo.deleteRecord( 118 + { repo: agent.did ?? '', collection: RECORD_TYPE, rkey: rec.uri.split('/').pop()! }, 119 + { signal } 120 + ); 121 + removed++; 122 + onProgress?.(removed); 123 + await new Promise<void>((resolve, reject) => { 124 + const t = setTimeout(resolve, 100); 125 + signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); }, { once: true }); 126 + }); 127 + } catch (err: unknown) { 128 + if (signal?.aborted) throw err; 129 + } 130 + } 131 + } 132 + return removed; 133 + }
+46
src/core/tid.ts
··· 1 + /** 2 + * TID (Timestamp Identifier) generation — environment-agnostic. 3 + * 4 + * Browser-safe: uses crypto.getRandomValues / globalThis.crypto. 5 + * In Node.js 20+ the Web Crypto API is available via globalThis.crypto as well. 6 + * 7 + * Spec: https://atproto.com/specs/tid 8 + */ 9 + 10 + // Base-32 alphabet used by AT Protocol (not standard base32) 11 + const S32_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; 12 + 13 + function s32encode(n: number): string { 14 + if (n === 0) return '2'; 15 + let s = ''; 16 + let val = n; 17 + while (val > 0) { 18 + s = S32_CHARS[val % 32] + s; 19 + val = Math.floor(val / 32); 20 + } 21 + return s; 22 + } 23 + 24 + // In-memory monotonic state (reset on each process/page load) 25 + let lastTimestampUs = 0; 26 + const clockId = (() => { 27 + const buf = new Uint8Array(1); 28 + (globalThis.crypto ?? (globalThis as any).webcrypto).getRandomValues(buf); 29 + return buf[0] % 32; 30 + })(); 31 + 32 + /** 33 + * Generate a TID from an ISO 8601 timestamp. 34 + * Guarantees monotonicity — timestamps that arrive out of order are bumped 35 + * forward so every call produces a strictly increasing TID. 36 + */ 37 + export async function generateTIDFromISO(isoString: string, _context?: string): Promise<string> { 38 + let timestamp = new Date(isoString).getTime() * 1000; // ms → µs 39 + if (timestamp <= lastTimestampUs) timestamp = lastTimestampUs + 1; 40 + lastTimestampUs = timestamp; 41 + return s32encode(timestamp).padStart(11, '2') + s32encode(clockId).padStart(2, '2'); 42 + } 43 + 44 + export function resetTidClock(): void { 45 + lastTimestampUs = 0; 46 + }
+70
src/core/types.ts
··· 1 + /** 2 + * Shared type definitions — environment-agnostic. 3 + * Used by both the CLI (src/lib/) and the web (web/src/lib/core/). 4 + */ 5 + 6 + export interface LastFmCsvRecord { 7 + uts: string; 8 + utc_time: string; 9 + artist: string; 10 + artist_mbid?: string; 11 + album: string; 12 + album_mbid?: string; 13 + track: string; 14 + track_mbid?: string; 15 + } 16 + 17 + export interface PlayRecordArtist { 18 + artistName: string; 19 + artistMbId?: string; 20 + } 21 + 22 + export interface PlayRecord { 23 + $type: string; 24 + trackName: string; 25 + artists: PlayRecordArtist[]; 26 + playedTime: string; 27 + submissionClientAgent: string; 28 + musicServiceBaseDomain: string; 29 + releaseName?: string; 30 + releaseMbId?: string; 31 + recordingMbId?: string; 32 + originUrl: string; 33 + } 34 + 35 + export interface PublishResult { 36 + successCount: number; 37 + errorCount: number; 38 + cancelled: boolean; 39 + } 40 + 41 + export type ImportMode = 'lastfm' | 'spotify' | 'combined' | 'sync' | 'deduplicate'; 42 + 43 + export interface SpotifyRecord { 44 + ts: string; 45 + platform: string; 46 + ms_played: number; 47 + conn_country: string; 48 + master_metadata_track_name: string | null; 49 + master_metadata_album_artist_name: string | null; 50 + master_metadata_album_album_name: string | null; 51 + spotify_track_uri: string | null; 52 + episode_name: string | null; 53 + episode_show_name: string | null; 54 + spotify_episode_uri: string | null; 55 + reason_start: string; 56 + reason_end: string; 57 + shuffle: boolean; 58 + skipped: boolean; 59 + offline: boolean; 60 + offline_timestamp: number | null; 61 + incognito_mode: boolean; 62 + } 63 + 64 + export type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'progress' | 'section'; 65 + 66 + export interface LogEntry { 67 + level: LogLevel; 68 + message: string; 69 + timestamp: number; 70 + }
+31 -96
src/lib/auth.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 - import { prompt } from '../utils/input.js'; 3 - import * as ui from '../utils/ui.js'; 4 - import { saveCredentials } from '../utils/credentials.js'; 5 - 6 - interface ResolvedIdentity { 7 - did: string; 8 - handle: string; 9 - pds: string; 10 - signing_key: string; 11 - } 12 - 13 1 /** 14 - * Resolves an AT Protocol identifier (handle or DID) to get PDS information 2 + * CLI authentication wrapper. 3 + * Adds terminal prompts and credential persistence on top of the core login. 15 4 */ 16 - async function resolveIdentifier(identifier: string, resolverBase: string): Promise<ResolvedIdentity> { 17 - ui.startSpinner(`Resolving identifier: ${identifier}`); 18 5 19 - try { 20 - const response = await fetch( 21 - `${resolverBase}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}` 22 - ); 23 - 24 - if (!response.ok) { 25 - throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`); 26 - } 27 - 28 - const data = await response.json() as ResolvedIdentity; 29 - 30 - if (!data.did || !data.pds) { 31 - throw new Error('Invalid response from identity resolver'); 32 - } 6 + import type { Agent } from '@atproto/api'; 7 + import { login as coreLogin, resolveIdentity } from '../core/auth.js'; 8 + import { prompt } from '../utils/input.js'; 9 + import * as ui from '../utils/ui.js'; 10 + import { saveCredentials } from '../utils/credentials.js'; 33 11 34 - ui.succeedSpinner(`Resolved to PDS: ${data.pds}`); 35 - return data; 36 - } catch (error) { 37 - ui.failSpinner('Failed to resolve identifier'); 38 - throw error; 39 - } 40 - } 12 + export { resolveIdentity }; 41 13 42 14 /** 43 - * Login to ATProto using Slingshot resolver 15 + * CLI-aware login: prompts for missing credentials, surfaces progress via 16 + * spinners, and automatically persists credentials on success. 44 17 */ 45 18 export async function login( 46 19 identifier: string | undefined, 47 20 password: string | undefined, 48 - resolverOrPds?: string // If this contains the Slingshot resolver base, it will be used to resolve; otherwise treated as a PDS override URL 49 - ): Promise<AtpAgent> { 21 + resolverOrPds?: string 22 + ): Promise<Agent> { 50 23 ui.header('ATProto Login'); 51 - 52 - // Prompt for missing credentials 24 + 53 25 if (!identifier) { 54 26 identifier = await prompt('Handle or DID: '); 55 27 } else { 56 28 ui.keyValue('Handle or DID', identifier); 57 29 } 58 - 30 + 59 31 if (!password) { 60 32 password = await prompt('App password: ', true); 61 33 } else { 62 34 ui.keyValue('App password', '[hidden]'); 63 35 } 64 - 36 + 65 37 console.log(''); 66 - 67 - try { 68 - // If resolverOrPds is provided and does NOT look like the Slingshot resolver, 69 - // treat it as a PDS override and skip identity resolution. 70 - const isSlingshot = resolverOrPds?.includes('slingshot') ?? false; 71 38 72 - if (resolverOrPds && !isSlingshot) { 73 - ui.startSpinner(`Using provided PDS: ${resolverOrPds}`); 74 - const agent = new AtpAgent({ service: resolverOrPds }); 75 - await agent.login({ identifier: identifier!, password: password }); 76 - ui.succeedSpinner('Logged in successfully (PDS override)!'); 77 - ui.keyValue('DID', agent.session?.did || 'unknown'); 78 - ui.keyValue('Handle', agent.session?.handle || 'unknown'); 79 - 80 - // Automatically save credentials (encrypted with SHA-512, machine-specific) 81 - try { 82 - saveCredentials(identifier, password); 83 - ui.info('Credentials saved securely (SHA-512 encrypted, machine-specific)'); 84 - } catch (err) { 85 - // Non-fatal - log but continue 86 - ui.warning('Failed to save credentials - you may need to re-enter them next time'); 87 - } 88 - 89 - console.log(''); 90 - return agent; 91 - } 39 + // If resolverOrPds looks like a direct PDS URL (not Slingshot), use it as-is. 40 + const isSlingshot = !resolverOrPds || resolverOrPds.includes('slingshot'); 41 + const pdsOverride = resolverOrPds && !isSlingshot ? resolverOrPds : undefined; 92 42 93 - // Otherwise use the resolver (provided or default) to resolve identifier 94 - const resolverBase = resolverOrPds || 'https://slingshot.microcosm.blue'; 95 - const resolved = await resolveIdentifier(identifier, resolverBase); 43 + try { 44 + ui.startSpinner(pdsOverride ? `Using provided PDS: ${pdsOverride}` : 'Resolving identity…'); 96 45 97 - // Initialize the agent with the resolved PDS URL 98 - ui.startSpinner('Logging in...'); 99 - const agent = new AtpAgent({ 100 - service: resolved.pds, 101 - }); 102 - 103 - // Attempt to login using the resolved DID for more reliable authentication 104 - await agent.login({ 105 - identifier: resolved.did, 106 - password: password, 107 - }); 46 + const agent = await coreLogin(identifier!, password!, pdsOverride); 108 47 109 48 ui.succeedSpinner('Logged in successfully!'); 110 - ui.keyValue('DID', agent.session?.did || 'unknown'); 111 - ui.keyValue('Handle', agent.session?.handle || 'unknown'); 112 - 113 - // Automatically save credentials (encrypted with SHA-512, machine-specific) 49 + ui.keyValue('DID', (agent as any).session?.did || (agent as any).did || 'unknown'); 50 + ui.keyValue('Handle', (agent as any).session?.handle || 'unknown'); 51 + 114 52 try { 115 - saveCredentials(identifier, password); 116 - ui.info('Credentials saved securely (SHA-512 encrypted, machine-specific)'); 117 - } catch (err) { 118 - // Non-fatal - log but continue 119 - ui.warning('Failed to save credentials - you may need to re-enter them next time'); 53 + saveCredentials(identifier!, password!); 54 + ui.info('Credentials saved securely'); 55 + } catch { 56 + ui.warning('Failed to save credentials — you may need to re-enter them next time'); 120 57 } 121 - 58 + 122 59 console.log(''); 123 - 124 60 return agent; 125 61 } catch (error) { 126 62 const err = error as Error; 127 63 ui.failSpinner('Login failed'); 128 - 129 - // Provide more specific error messages 130 - if (err.message.includes('Failed to resolve identifier')) { 64 + 65 + if (err.message.includes('Failed to resolve identity')) { 131 66 throw new Error('Handle not found. Please check your AT Protocol handle.'); 132 67 } else if (err.message.includes('AuthFactorTokenRequired')) { 133 68 throw new Error('Two-factor authentication required. Please use your app password.');
+23 -219
src/lib/csv.ts
··· 1 - import * as fs from 'fs'; 2 - import { parse } from 'csv-parse/sync'; 3 - import type { LastFmCsvRecord, PlayRecord, Config } from '../types.js'; 4 - import { buildClientAgent } from '../config.js'; 5 - 6 - // ─── web: boolean toggle ──────────────────────────────────────────────────── 7 - // When `isWeb` is true the caller is responsible for supplying raw file content 8 - // as a string (no fs access). All shared parsing logic lives in the 9 - // `*Content` variants below; the Node-only wrappers stay for the CLI. 10 - // ──────────────────────────────────────────────────────────────────────────── 11 - 12 1 /** 13 - * Detect CSV delimiter by checking first line 14 - */ 15 - function detectDelimiter(content: string): string { 16 - const firstLine = content.split('\n')[0]; 17 - const delimiters = [',', ';', '\t', '|']; 18 - 19 - let maxCount = 0; 20 - let detectedDelimiter = ','; 21 - 22 - for (const delimiter of delimiters) { 23 - const count = firstLine.split(delimiter).length; 24 - if (count > maxCount) { 25 - maxCount = count; 26 - detectedDelimiter = delimiter; 27 - } 28 - } 29 - 30 - return detectedDelimiter; 31 - } 32 - 33 - /** 34 - * Normalize column names to expected format 35 - */ 36 - function normalizeColumns(record: any): LastFmCsvRecord { 37 - // Create a mapping of possible column names to our expected format 38 - const columnMappings: { [key: string]: string } = { 39 - // Timestamp fields 40 - 'uts': 'uts', 41 - 'date': 'uts', 42 - 'timestamp': 'uts', 43 - 'played_at': 'uts', 44 - 'time': 'uts', 45 - 46 - // Artist fields 47 - 'artist': 'artist', 48 - 'artist_name': 'artist', 49 - 'artistname': 'artist', 50 - 51 - // Artist MBID fields 52 - 'artist_mbid': 'artist_mbid', 53 - 'artistmbid': 'artist_mbid', 54 - 'artist_id': 'artist_mbid', 55 - 56 - // Album fields 57 - 'album': 'album', 58 - 'album_name': 'album', 59 - 'albumname': 'album', 60 - 'release': 'album', 61 - 62 - // Album MBID fields 63 - 'album_mbid': 'album_mbid', 64 - 'albummbid': 'album_mbid', 65 - 'albumid': 'album_mbid', 66 - 'album_id': 'album_mbid', 67 - 68 - // Track fields 69 - 'track': 'track', 70 - 'track_name': 'track', 71 - 'trackname': 'track', 72 - 'song': 'track', 73 - 'title': 'track', 74 - 75 - // Track MBID fields 76 - 'track_mbid': 'track_mbid', 77 - 'trackmbid': 'track_mbid', 78 - 'track_id': 'track_mbid', 79 - 80 - // UTC time field 81 - 'utc_time': 'utc_time', 82 - 'utctime': 'utc_time', 83 - 'datetime': 'utc_time', 84 - }; 85 - 86 - const normalized: any = {}; 87 - 88 - // Convert all keys to lowercase for matching 89 - const recordLowercase: { [key: string]: any } = {}; 90 - for (const [key, value] of Object.entries(record)) { 91 - recordLowercase[key.toLowerCase()] = value; 92 - } 93 - 94 - // Map columns to expected names 95 - for (const [originalName, mappedName] of Object.entries(columnMappings)) { 96 - if (recordLowercase[originalName] !== undefined) { 97 - normalized[mappedName] = recordLowercase[originalName]; 98 - } 99 - } 100 - 101 - // Handle timestamp conversion 102 - if (normalized.uts) { 103 - const timestamp = normalized.uts.toString(); 104 - // If timestamp is in milliseconds (13+ digits), convert to seconds 105 - if (timestamp.length >= 13) { 106 - normalized.uts = Math.floor(parseInt(timestamp) / 1000).toString(); 107 - } 108 - } 109 - 110 - // Generate utc_time from uts if not present 111 - if (normalized.uts && !normalized.utc_time) { 112 - const date = new Date(parseInt(normalized.uts) * 1000); 113 - normalized.utc_time = date.toISOString(); 114 - } 115 - 116 - return normalized as LastFmCsvRecord; 117 - } 118 - 119 - /** 120 - * Parse Last.fm CSV string content (browser-safe, no fs dependency). 121 - * This is the shared core used by both the CLI and the web app. 2 + * Last.fm CSV — CLI wrapper. 3 + * Re-exports the environment-agnostic core and adds a Node.js fs loader. 122 4 */ 123 - export function parseLastFmCsvContent(rawContent: string): LastFmCsvRecord[] { 124 - let fileContent = rawContent; 125 5 126 - // Remove BOM if present 127 - if (fileContent.charCodeAt(0) === 0xFEFF) { 128 - fileContent = fileContent.slice(1); 129 - } 6 + import * as fs from 'fs'; 7 + import type { LastFmCsvRecord, PlayRecord } from '../types.js'; 8 + import { parseLastFmCsvContent, convertToPlayRecord as coreConvert } from '../core/csv.js'; 9 + import { VERSION } from '../core/config.js'; 130 10 131 - // Clean up header line – remove anything after # (e.g. username) 132 - const lines = fileContent.split('\n'); 133 - if (lines.length > 0) { 134 - lines[0] = lines[0].split('#')[0].trim(); 135 - fileContent = lines.join('\n'); 136 - } 11 + export { parseLastFmCsvContent }; 12 + export type { LastFmCsvRecord }; 137 13 138 - const delimiter = detectDelimiter(fileContent); 139 - 140 - try { 141 - const rawRecords = parse(fileContent, { 142 - columns: true, 143 - skip_empty_lines: true, 144 - trim: true, 145 - delimiter, 146 - relax_quotes: true, 147 - relax_column_count: true, 148 - }); 149 - 150 - const records = rawRecords.map(normalizeColumns); 151 - 152 - const validRecords = records.filter((record: LastFmCsvRecord) => { 153 - return record.artist && record.track && record.uts; 154 - }); 155 - 156 - if (validRecords.length === 0) { 157 - console.error('\n⚠️ Warning: No valid records found after parsing.'); 158 - console.error(' Required fields: artist, track, and timestamp'); 159 - console.error(' Available columns:', Object.keys(rawRecords[0] || {})); 160 - } 161 - 162 - console.log(`✓ Parsed ${validRecords.length} scrobbles\n`); 163 - return validRecords; 164 - } catch (error) { 165 - console.error('\n🛑 CSV parsing failed:'); 166 - console.error(' ', error); 167 - console.error('\n Tip: Make sure your CSV has columns for artist, track, and timestamp'); 168 - throw error; 169 - } 170 - } 14 + const CLI_AGENT = `malachite/v${VERSION}`; 171 15 172 16 /** 173 - * Parse Last.fm CSV export with dynamic delimiter detection and column mapping 17 + * Read a Last.fm CSV file from disk and return normalised records. 174 18 */ 175 19 export function parseLastFmCsv(filePath: string): LastFmCsvRecord[] { 176 20 console.log(`Reading CSV file: ${filePath}`); 177 - const fileContent = fs.readFileSync(filePath, 'utf-8'); 178 - return parseLastFmCsvContent(fileContent); 21 + const content = fs.readFileSync(filePath, 'utf-8'); 22 + const records = parseLastFmCsvContent(content); 23 + console.log(`✓ Parsed ${records.length} scrobbles\n`); 24 + return records; 179 25 } 180 26 181 - 182 27 /** 183 - * Convert Last.fm CSV record to ATProto play record 28 + * Convert a normalised Last.fm CSV record to an ATProto play record. 29 + * The CLI agent string is injected automatically; pass `debug=true` for 30 + * future extension (currently has no effect). 184 31 */ 185 - export function convertToPlayRecord(csvRecord: LastFmCsvRecord, config: Config, debug = false): PlayRecord { 186 - const { RECORD_TYPE } = config; 187 - 188 - // Parse the timestamp 189 - const timestamp = parseInt(csvRecord.uts); 190 - const playedTime = new Date(timestamp * 1000).toISOString(); 191 - 192 - // Build artists array 193 - const artists: PlayRecord['artists'] = []; 194 - if (csvRecord.artist) { 195 - const artistData: PlayRecord['artists'][0] = { 196 - artistName: csvRecord.artist, 197 - }; 198 - if (csvRecord.artist_mbid && csvRecord.artist_mbid.trim()) { 199 - artistData.artistMbId = csvRecord.artist_mbid; 200 - } 201 - artists.push(artistData); 202 - } 203 - 204 - // Build the play record 205 - const playRecord: PlayRecord = { 206 - $type: RECORD_TYPE, 207 - trackName: csvRecord.track, 208 - artists, 209 - playedTime, 210 - submissionClientAgent: buildClientAgent(debug), 211 - musicServiceBaseDomain: 'last.fm', 212 - originUrl: '', 213 - }; 214 - 215 - // Add optional fields 216 - if (csvRecord.album && csvRecord.album.trim()) { 217 - playRecord.releaseName = csvRecord.album; 218 - } 219 - 220 - if (csvRecord.album_mbid && csvRecord.album_mbid.trim()) { 221 - playRecord.releaseMbId = csvRecord.album_mbid; 222 - } 223 - 224 - if (csvRecord.track_mbid && csvRecord.track_mbid.trim()) { 225 - playRecord.recordingMbId = csvRecord.track_mbid; 226 - } 227 - 228 - // Generate Last.fm URL 229 - const artistEncoded = encodeURIComponent(csvRecord.artist); 230 - const trackEncoded = encodeURIComponent(csvRecord.track); 231 - playRecord.originUrl = `https://www.last.fm/music/${artistEncoded}/_/${trackEncoded}`; 232 - 233 - return playRecord; 32 + export function convertToPlayRecord( 33 + csv: LastFmCsvRecord, 34 + _configOrUnused?: unknown, 35 + _debug?: boolean 36 + ): PlayRecord { 37 + return coreConvert(csv, CLI_AGENT); 234 38 }
+5 -3
src/lib/sync.ts
··· 1 1 import type { AtpAgent } from '@atproto/api'; 2 2 import type { PlayRecord, Config } from '../types.js'; 3 - import { fetchRepoViaCAR, getPdsUrlFromAgent } from '../utils/car-fetch.js'; 3 + import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken } from '../utils/car-fetch.js'; 4 4 import { formatDate, formatDateRange } from '../utils/helpers.js'; 5 5 import * as ui from '../utils/ui.js'; 6 6 import { log } from '../utils/logger.js'; ··· 59 59 } 60 60 61 61 const pdsUrl = getPdsUrlFromAgent(agent); 62 + const token = await getAgentToken(agent); 62 63 const carStart = Date.now(); 63 - const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE); 64 + const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, undefined, token); 64 65 const carElapsed = ((Date.now() - carStart) / 1000).toFixed(1); 65 66 66 67 const existingRecords = new Map<string, ExistingRecord>(); ··· 97 98 ui.startSpinner('📦 Fetching repo via CAR export...'); 98 99 99 100 const pdsUrl = getPdsUrlFromAgent(agent); 100 - const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE); 101 + const token = await getAgentToken(agent); 102 + const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, undefined, token); 101 103 const allRecords: ExistingRecord[] = carRecords.map((rec) => ({ 102 104 uri: rec.uri, 103 105 cid: rec.cid,
+4 -150
src/utils/car-fetch.ts
··· 1 - /** 2 - * CAR export fetcher for ATProto repos. 3 - * 4 - * Calls com.atproto.sync.getRepo (sync namespace) — separate, far more 5 - * generous rate-limit envelope from the AppView. One HTTP request downloads 6 - * the entire repo as a CARv1 file, which is then parsed locally. 7 - * 8 - * Dependencies: @ipld/car @ipld/dag-cbor multiformats 9 - */ 10 - 11 - import { CarReader } from '@ipld/car'; 12 - import * as dagCbor from '@ipld/dag-cbor'; 13 - import type { CID } from 'multiformats'; 14 - 15 - // ─── ATProto repo CBOR shapes ───────────────────────────────────────────────── 16 - 17 - interface RepoCommit { 18 - version: number; 19 - did: string; 20 - data: CID; // MST root 21 - rev: string; 22 - sig: Uint8Array; 23 - } 24 - 25 - interface MSTNode { 26 - l: CID | null; 27 - e: Array<{ 28 - p: number; // bytes of previous key to reuse as prefix 29 - k: Uint8Array; // key suffix bytes 30 - v: CID; // record CID 31 - t: CID | null; // right subtree CID 32 - }>; 33 - } 34 - 35 - // ─── helpers ───────────────────────────────────────────────────────────────── 36 - 37 - function cidStr(cid: CID): string { 38 - return cid.toString(); 39 - } 40 - 41 - async function buildBlockMap(reader: CarReader): Promise<Map<string, Uint8Array>> { 42 - const blocks = new Map<string, Uint8Array>(); 43 - for await (const { cid, bytes } of reader.blocks()) { 44 - blocks.set(cidStr(cid), bytes); 45 - } 46 - return blocks; 47 - } 48 - 49 - async function walkMST( 50 - rootCid: CID, 51 - blocks: Map<string, Uint8Array>, 52 - collection: string, 53 - onRecord: (rkey: string, cid: string, value: unknown) => void, 54 - prevKey = '', 55 - ): Promise<string> { 56 - const nodeBytes = blocks.get(cidStr(rootCid)); 57 - if (!nodeBytes) return prevKey; 58 - 59 - const node = dagCbor.decode(nodeBytes) as MSTNode; 60 - let currentKey = prevKey; 61 - 62 - if (node.l) { 63 - currentKey = await walkMST(node.l, blocks, collection, onRecord, currentKey); 64 - } 65 - 66 - for (const entry of node.e ?? []) { 67 - const fullKey = currentKey.slice(0, entry.p) + new TextDecoder().decode(entry.k); 68 - currentKey = fullKey; 69 - 70 - const collPrefix = collection + '/'; 71 - if (fullKey.startsWith(collPrefix)) { 72 - const rkey = fullKey.slice(collPrefix.length); 73 - const valBytes = blocks.get(cidStr(entry.v)); 74 - if (valBytes) { 75 - try { 76 - onRecord(rkey, cidStr(entry.v), dagCbor.decode(valBytes)); 77 - } catch { 78 - // malformed block — skip silently 79 - } 80 - } 81 - } 82 - 83 - if (entry.t) { 84 - currentKey = await walkMST(entry.t, blocks, collection, onRecord, currentKey); 85 - } 86 - } 87 - 88 - return currentKey; 89 - } 90 - 91 - // ─── public API ────────────────────────────────────────────────────────────── 92 - 93 - export interface CARRecord { 94 - rkey: string; 95 - uri: string; 96 - cid: string; 97 - value: unknown; 98 - } 99 - 100 - /** 101 - * Fetch a user's entire ATProto repo as a CAR file and extract all records 102 - * from `collection`. 103 - */ 104 - export async function fetchRepoViaCAR( 105 - pdsUrl: string, 106 - did: string, 107 - collection: string, 108 - ): Promise<CARRecord[]> { 109 - const url = `${pdsUrl.replace(/\/$/, '')}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; 110 - 111 - const response = await fetch(url, { 112 - headers: { Accept: 'application/vnd.ipld.car' }, 113 - }); 114 - 115 - if (!response.ok) { 116 - throw new Error(`CAR fetch failed: ${response.status} ${response.statusText}`); 117 - } 118 - 119 - const carBytes = new Uint8Array(await response.arrayBuffer()); 120 - const reader = await CarReader.fromBytes(carBytes); 121 - const blocks = await buildBlockMap(reader); 122 - 123 - const [rootCid] = await reader.getRoots(); 124 - if (!rootCid) throw new Error('CAR file has no roots'); 125 - 126 - const commitBytes = blocks.get(cidStr(rootCid)); 127 - if (!commitBytes) throw new Error('Commit block missing from CAR'); 128 - 129 - const commit = dagCbor.decode(commitBytes) as RepoCommit; 130 - if (!commit.data) throw new Error('Commit has no MST root CID'); 131 - 132 - const results: CARRecord[] = []; 133 - await walkMST(commit.data, blocks, collection, (rkey, cid, value) => { 134 - results.push({ rkey, uri: `at://${did}/${collection}/${rkey}`, cid, value }); 135 - }); 136 - 137 - return results; 138 - } 139 - 140 - /** 141 - * Extract the PDS base URL from an @atproto/api AtpAgent. 142 - */ 143 - export function getPdsUrlFromAgent(agent: unknown): string { 144 - const a = agent as Record<string, unknown>; 145 - for (const field of ['dispatchUrl', 'pdsUrl', 'serviceUrl', 'service']) { 146 - const v = a[field] ?? (a['sessionManager'] as any)?.[field]; 147 - if (v) return v.toString(); 148 - } 149 - throw new Error('Cannot determine PDS URL from agent — pass --pds <url> explicitly'); 150 - } 1 + // Redirect to the single source of truth in src/core/. 2 + // src/lib/sync.ts (legacy CLI path) imports from here; keep this shim so no 3 + // other files need updating. 4 + export * from '../core/car-fetch.js';
+1 -1
web/package.json
··· 1 1 { 2 2 "name": "web", 3 3 "private": true, 4 - "version": "0.3.0", 4 + "version": "0.3.1", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev",
+3 -7
web/src/lib/config.ts
··· 1 - // Shared constants — mirrors src/config.ts without Node.js deps. 2 - 3 - export const RECORD_TYPE = 'fm.teal.alpha.feed.play'; 4 - export const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue'; 5 - export const MAX_PDS_BATCH_SIZE = 200; 6 - export const POINTS_PER_RECORD = 3; 1 + // Re-export shared constants from the environment-agnostic core. 2 + // Keep this file free of side-effects so it stays tree-shakeable. 3 + export { RECORD_TYPE, SLINGSHOT_RESOLVER, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from '$core/config.js'; 7 4 8 5 // __WEB_VERSION__ is injected at build time by vite.config.ts → define.__WEB_VERSION__ 9 - // Keep this file free of import statements so it stays side-effect-free. 10 6 declare const __WEB_VERSION__: string; 11 7 export const CLIENT_AGENT = `malachite/v${__WEB_VERSION__} (web)`;
+2 -162
web/src/lib/core/car-fetch.ts
··· 1 - /** 2 - * CAR export fetcher for ATProto repos — browser-safe, no Node.js deps. 3 - * 4 - * Calls com.atproto.sync.getRepo (sync namespace) — separate, far more 5 - * generous rate-limit envelope from the AppView. One HTTP request downloads 6 - * the entire repo as a CARv1 file, which is then parsed locally. 7 - * 8 - * Dependencies: @ipld/car @ipld/dag-cbor multiformats 9 - */ 10 - 11 - import { CarReader } from '@ipld/car'; 12 - import * as dagCbor from '@ipld/dag-cbor'; 13 - import type { CID } from 'multiformats'; 14 - 15 - // ─── ATProto repo CBOR shapes ───────────────────────────────────────────────── 16 - 17 - interface RepoCommit { 18 - version: number; 19 - did: string; 20 - data: CID; // MST root 21 - rev: string; 22 - sig: Uint8Array; 23 - } 24 - 25 - interface MSTNode { 26 - l: CID | null; 27 - e: Array<{ 28 - p: number; // bytes of previous key to reuse as prefix 29 - k: Uint8Array; // key suffix bytes 30 - v: CID; // record CID 31 - t: CID | null; // right subtree CID 32 - }>; 33 - } 34 - 35 - // ─── helpers ───────────────────────────────────────────────────────────────── 36 - 37 - function cidStr(cid: CID): string { 38 - return cid.toString(); 39 - } 40 - 41 - async function buildBlockMap(reader: CarReader): Promise<Map<string, Uint8Array>> { 42 - const blocks = new Map<string, Uint8Array>(); 43 - for await (const { cid, bytes } of reader.blocks()) { 44 - blocks.set(cidStr(cid), bytes); 45 - } 46 - return blocks; 47 - } 48 - 49 - async function walkMST( 50 - rootCid: CID, 51 - blocks: Map<string, Uint8Array>, 52 - collection: string, 53 - onRecord: (rkey: string, cid: string, value: unknown) => void, 54 - prevKey = '', 55 - ): Promise<string> { 56 - const nodeBytes = blocks.get(cidStr(rootCid)); 57 - if (!nodeBytes) return prevKey; 58 - 59 - const node = dagCbor.decode(nodeBytes) as MSTNode; 60 - let currentKey = prevKey; 61 - 62 - if (node.l) { 63 - currentKey = await walkMST(node.l, blocks, collection, onRecord, currentKey); 64 - } 65 - 66 - for (const entry of node.e ?? []) { 67 - const fullKey = currentKey.slice(0, entry.p) + new TextDecoder().decode(entry.k); 68 - currentKey = fullKey; 69 - 70 - const collPrefix = collection + '/'; 71 - if (fullKey.startsWith(collPrefix)) { 72 - const rkey = fullKey.slice(collPrefix.length); 73 - const valBytes = blocks.get(cidStr(entry.v)); 74 - if (valBytes) { 75 - try { 76 - onRecord(rkey, cidStr(entry.v), dagCbor.decode(valBytes)); 77 - } catch { 78 - // malformed block — skip silently 79 - } 80 - } 81 - } 82 - 83 - if (entry.t) { 84 - currentKey = await walkMST(entry.t, blocks, collection, onRecord, currentKey); 85 - } 86 - } 87 - 88 - return currentKey; 89 - } 90 - 91 - // ─── public API ────────────────────────────────────────────────────────────── 92 - 93 - export interface CARRecord { 94 - rkey: string; 95 - uri: string; 96 - cid: string; 97 - value: unknown; 98 - } 99 - 100 - /** 101 - * Fetch a user's entire ATProto repo as a CAR file and extract all records 102 - * from `collection`. 103 - * 104 - * @param signal Optional AbortSignal — cancels the download mid-flight. 105 - */ 106 - export async function fetchRepoViaCAR( 107 - pdsUrl: string, 108 - did: string, 109 - collection: string, 110 - signal?: AbortSignal, 111 - ): Promise<CARRecord[]> { 112 - const url = `${pdsUrl.replace(/\/$/, '')}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; 113 - 114 - const response = await fetch(url, { 115 - headers: { Accept: 'application/vnd.ipld.car' }, 116 - signal, 117 - }); 118 - 119 - if (!response.ok) { 120 - throw new Error(`CAR fetch failed: ${response.status} ${response.statusText}`); 121 - } 122 - 123 - const carBytes = new Uint8Array(await response.arrayBuffer()); 124 - const reader = await CarReader.fromBytes(carBytes); 125 - const blocks = await buildBlockMap(reader); 126 - 127 - const [rootCid] = await reader.getRoots(); 128 - if (!rootCid) throw new Error('CAR file has no roots'); 129 - 130 - const commitBytes = blocks.get(cidStr(rootCid)); 131 - if (!commitBytes) throw new Error('Commit block missing from CAR'); 132 - 133 - const commit = dagCbor.decode(commitBytes) as RepoCommit; 134 - if (!commit.data) throw new Error('Commit has no MST root CID'); 135 - 136 - const results: CARRecord[] = []; 137 - await walkMST(commit.data, blocks, collection, (rkey, cid, value) => { 138 - results.push({ rkey, uri: `at://${did}/${collection}/${rkey}`, cid, value }); 139 - }); 140 - 141 - return results; 142 - } 143 - 144 - /** 145 - * Extract the PDS base URL from an @atproto/api Agent or AtpAgent. 146 - * Returns null if it cannot be determined (caller should surface a useful error). 147 - */ 148 - export function getPdsUrlFromAgent(agent: unknown): string { 149 - const a = agent as Record<string, unknown>; 150 - 151 - // OAuth agent: session manager carries serverMetadata.issuer as the PDS base URL. 152 - const issuer = (a['sessionManager'] as any)?.serverMetadata?.issuer; 153 - if (issuer) return issuer.toString(); 154 - 155 - // AtpAgent / password-auth agent: direct URL fields. 156 - for (const field of ['dispatchUrl', 'pdsUrl', 'serviceUrl', 'service']) { 157 - const v = a[field] ?? (a['sessionManager'] as any)?.[field]; 158 - if (v) return v.toString(); 159 - } 160 - 161 - throw new Error('Cannot determine PDS URL from agent'); 162 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + export * from '$core/car-fetch.js';
+6 -136
web/src/lib/core/csv.ts
··· 1 1 /** 2 - * Browser-compatible Last.fm CSV parser. 3 - * 4 - * The parsing logic mirrors src/lib/csv.ts → parseLastFmCsvContent. 5 - * We avoid csv-parse (uses Node streams) and write a minimal parser instead. 2 + * Last.fm CSV — web layer. 3 + * Re-exports the shared core logic and adds a browser File API loader. 6 4 */ 7 - 8 - import type { LastFmCsvRecord, PlayRecord } from '../types.js'; 9 - import { RECORD_TYPE, CLIENT_AGENT } from '../config.js'; 10 - 11 - // ─── delimiter detection ─────────────────────────────────────────────────── 12 - 13 - function detectDelimiter(content: string): string { 14 - const firstLine = content.split('\n')[0]; 15 - const delimiters = [',', ';', '\t', '|']; 16 - let maxCount = 0; 17 - let best = ','; 18 - for (const d of delimiters) { 19 - const count = firstLine.split(d).length; 20 - if (count > maxCount) { maxCount = count; best = d; } 21 - } 22 - return best; 23 - } 24 - 25 - // ─── column normalisation ────────────────────────────────────────────────── 26 - 27 - const COLUMN_MAP: Record<string, string> = { 28 - uts: 'uts', date: 'uts', timestamp: 'uts', played_at: 'uts', time: 'uts', 29 - artist: 'artist', artist_name: 'artist', artistname: 'artist', 30 - artist_mbid: 'artist_mbid', artistmbid: 'artist_mbid', artist_id: 'artist_mbid', 31 - album: 'album', album_name: 'album', albumname: 'album', release: 'album', 32 - album_mbid: 'album_mbid', albummbid: 'album_mbid', albumid: 'album_mbid', album_id: 'album_mbid', 33 - track: 'track', track_name: 'track', trackname: 'track', song: 'track', title: 'track', 34 - track_mbid: 'track_mbid', trackmbid: 'track_mbid', track_id: 'track_mbid', 35 - utc_time: 'utc_time', utctime: 'utc_time', datetime: 'utc_time' 36 - }; 37 - 38 - function normalizeRecord(raw: Record<string, string>): LastFmCsvRecord { 39 - const normalized: Record<string, string> = {}; 40 - for (const [k, v] of Object.entries(raw)) { 41 - const mapped = COLUMN_MAP[k.toLowerCase()]; 42 - if (mapped) normalized[mapped] = v; 43 - } 44 - 45 - // Timestamp normalisation 46 - if (normalized.uts) { 47 - const ts = normalized.uts.toString(); 48 - if (ts.length >= 13) normalized.uts = Math.floor(parseInt(ts) / 1000).toString(); 49 - } 50 - if (normalized.uts && !normalized.utc_time) { 51 - normalized.utc_time = new Date(parseInt(normalized.uts) * 1000).toISOString(); 52 - } 53 - return normalized as unknown as LastFmCsvRecord; 54 - } 55 - 56 - // ─── minimal CSV parser ──────────────────────────────────────────────────── 57 - 58 - function parseCSV(content: string, delimiter: string): Record<string, string>[] { 59 - const lines = content.split(/\r?\n/).filter((l) => l.trim()); 60 - if (lines.length < 2) return []; 61 - 62 - const parseRow = (line: string): string[] => { 63 - const cells: string[] = []; 64 - let cur = ''; 65 - let inQuote = false; 66 - for (let i = 0; i < line.length; i++) { 67 - const ch = line[i]; 68 - if (ch === '"') { 69 - if (inQuote && line[i + 1] === '"') { cur += '"'; i++; } 70 - else inQuote = !inQuote; 71 - } else if (ch === delimiter && !inQuote) { 72 - cells.push(cur.trim()); cur = ''; 73 - } else { 74 - cur += ch; 75 - } 76 - } 77 - cells.push(cur.trim()); 78 - return cells; 79 - }; 80 - 81 - const headers = parseRow(lines[0]); 82 - const records: Record<string, string>[] = []; 83 - for (let i = 1; i < lines.length; i++) { 84 - const cells = parseRow(lines[i]); 85 - const record: Record<string, string> = {}; 86 - headers.forEach((h, idx) => { record[h] = cells[idx] ?? ''; }); 87 - records.push(record); 88 - } 89 - return records; 90 - } 91 - 92 - // ─── public API ─────────────────────────────────────────────────────────── 5 + import type { LastFmCsvRecord } from '$core/types.js'; 6 + import { parseLastFmCsvContent, convertToPlayRecord } from '$core/csv.js'; 93 7 94 - export function parseLastFmCsvContent(rawContent: string): LastFmCsvRecord[] { 95 - let content = rawContent; 96 - // Strip BOM 97 - if (content.charCodeAt(0) === 0xfeff) content = content.slice(1); 98 - // Strip username comment from header 99 - const lines = content.split('\n'); 100 - lines[0] = lines[0].split('#')[0].trim(); 101 - content = lines.join('\n'); 8 + export { parseLastFmCsvContent, convertToPlayRecord }; 102 9 103 - const delimiter = detectDelimiter(content); 104 - const raw = parseCSV(content, delimiter); 105 - const records = raw.map(normalizeRecord); 106 - return records.filter((r) => r.artist && r.track && r.uts); 107 - } 108 - 10 + /** Read a browser File object and parse it as a Last.fm CSV export. */ 109 11 export async function parseLastFmFile(file: File): Promise<LastFmCsvRecord[]> { 110 12 const text = await file.text(); 111 13 return parseLastFmCsvContent(text); 112 14 } 113 - 114 - export function convertToPlayRecord(csv: LastFmCsvRecord): PlayRecord { 115 - const timestamp = parseInt(csv.uts); 116 - const playedTime = new Date(timestamp * 1000).toISOString(); 117 - 118 - const artists: PlayRecord['artists'] = []; 119 - if (csv.artist) { 120 - const a: PlayRecord['artists'][0] = { artistName: csv.artist }; 121 - if (csv.artist_mbid?.trim()) a.artistMbId = csv.artist_mbid; 122 - artists.push(a); 123 - } 124 - 125 - const record: PlayRecord = { 126 - $type: RECORD_TYPE, 127 - trackName: csv.track, 128 - artists, 129 - playedTime, 130 - submissionClientAgent: CLIENT_AGENT, 131 - musicServiceBaseDomain: 'last.fm', 132 - originUrl: '' 133 - }; 134 - 135 - if (csv.album?.trim()) record.releaseName = csv.album; 136 - if (csv.album_mbid?.trim()) record.releaseMbId = csv.album_mbid; 137 - if (csv.track_mbid?.trim()) record.recordingMbId = csv.track_mbid; 138 - 139 - const aEnc = encodeURIComponent(csv.artist); 140 - const tEnc = encodeURIComponent(csv.track); 141 - record.originUrl = `https://www.last.fm/music/${aEnc}/_/${tEnc}`; 142 - 143 - return record; 144 - }
+15 -12
web/src/lib/core/import.ts
··· 1 1 /** 2 2 * Import orchestration logic — pure TypeScript, no Svelte deps. 3 3 * Handles all five ImportMode flows with progress + cancellation callbacks. 4 + * 5 + * All heavy logic (publisher, sync, merge) lives in src/core/ and is shared 6 + * with the CLI. Only the browser File-loading helpers and this orchestrator 7 + * are web-specific. 4 8 */ 5 9 6 10 import type { Agent } from '@atproto/api'; 7 - import type { ImportMode, LogEntry, PlayRecord } from '../types.js'; 11 + import type { ImportMode, LogEntry, PlayRecord } from '$core/types.js'; 12 + import { CLIENT_AGENT } from '../config.js'; 8 13 import { parseLastFmFile, convertToPlayRecord } from './csv.js'; 9 14 import { parseSpotifyFiles, convertSpotifyToPlayRecord } from './spotify.js'; 10 - import { mergePlayRecords, deduplicateInputRecords, sortRecords } from './merge.js'; 15 + import { mergePlayRecords, deduplicateInputRecords, sortRecords } from '$core/merge.js'; 11 16 import { 12 17 fetchExistingRecords, 13 18 filterNewRecords, 14 19 fetchAllRecordsForDedup, 15 20 findDuplicateGroups, 16 21 removeDuplicateRecords, 17 - } from './sync.js'; 18 - import { publishRecords, type PublishProgress } from './publisher.js'; 22 + } from '$core/sync.js'; 23 + import { publishRecords, type PublishProgress } from '$core/publisher.js'; 19 24 20 25 export type { PublishProgress }; 21 26 ··· 46 51 { onLog, onProgress, isCancelled }: ImportCallbacks, 47 52 ): Promise<ImportResult> { 48 53 // Single AbortController for every network call in this run. 49 - // A 50 ms poll drives it from isCancelled so all awaited fetches are cut off 50 - // immediately — even mid-page-fetch — without callers needing to know about it. 51 54 const ac = new AbortController(); 52 55 const poll = setInterval(() => { if (isCancelled()) ac.abort(); }, 50); 53 56 const sig = ac.signal; 54 57 55 - // Wrap every abort error into a clean cancelled result. 56 58 const run = async (): Promise<ImportResult> => { 57 59 // ── Deduplicate mode ───────────────────────────────────────────────────── 58 60 if (mode === 'deduplicate') { ··· 100 102 const spRaw = await parseSpotifyFiles(spotifyFiles); 101 103 onLog('info', `Spotify: ${spRaw.length.toLocaleString()} tracks`); 102 104 const { merged, stats } = mergePlayRecords( 103 - lfRaw.map((r) => convertToPlayRecord(r)), 104 - spRaw.map((r) => convertSpotifyToPlayRecord(r)), 105 + lfRaw.map((r) => convertToPlayRecord(r, CLIENT_AGENT)), 106 + spRaw.map((r) => convertSpotifyToPlayRecord(r, CLIENT_AGENT)), 105 107 ); 106 108 records = merged; 107 109 onLog('success', `Merged: ${stats.mergedTotal.toLocaleString()} unique records (${stats.duplicatesRemoved} removed)`); 108 110 } else if (mode === 'spotify') { 109 111 const spRaw = await parseSpotifyFiles(spotifyFiles); 110 - records = spRaw.map((r) => convertSpotifyToPlayRecord(r)); 112 + records = spRaw.map((r) => convertSpotifyToPlayRecord(r, CLIENT_AGENT)); 111 113 onLog('success', `Loaded ${records.length.toLocaleString()} Spotify records`); 112 114 } else { 113 115 const lfRaw = await parseLastFmFile(lastfmFiles[0]); 114 - records = lfRaw.map((r) => convertToPlayRecord(r)); 116 + records = lfRaw.map((r) => convertToPlayRecord(r, CLIENT_AGENT)); 115 117 onLog('success', `Loaded ${records.length.toLocaleString()} Last.fm records`); 116 118 } 117 119 ··· 142 144 143 145 // ── Publish ────────────────────────────────────────────────────────────── 144 146 onLog('section', '── Publishing ───────────────────────────────────────'); 147 + onLog('warn', 'Do not close this tab while publishing.'); 145 148 const res = await publishRecords(agent, records, dryRun, { 146 149 onProgress, 147 150 onLog: (level, msg) => onLog(level as LogEntry['level'], msg), ··· 152 155 153 156 try { 154 157 return await run(); 155 - } catch (err: any) { 158 + } catch (err: unknown) { 156 159 if (ac.signal.aborted) return { success: 0, errors: 0, cancelled: true }; 157 160 throw err; 158 161 } finally {
+2 -113
web/src/lib/core/merge.ts
··· 1 - /** 2 - * Browser-compatible merge logic. 3 - * Mirrors src/lib/merge.ts without Node.js deps or file I/O. 4 - */ 5 - 6 - import type { PlayRecord } from '../types.js'; 7 - 8 - function normalizeString(s: string): string { 9 - return s.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); 10 - } 11 - 12 - interface NormalizedRecord { 13 - original: PlayRecord; 14 - normalizedTrack: string; 15 - normalizedArtist: string; 16 - timestamp: number; 17 - source: 'lastfm' | 'spotify'; 18 - } 19 - 20 - function areDuplicates(a: NormalizedRecord, b: NormalizedRecord): boolean { 21 - return ( 22 - Math.abs(a.timestamp - b.timestamp) <= 300_000 && 23 - a.normalizedTrack === b.normalizedTrack && 24 - a.normalizedArtist === b.normalizedArtist 25 - ); 26 - } 27 - 28 - function betterRecord(a: NormalizedRecord, b: NormalizedRecord): PlayRecord { 29 - const hasMb = (n: NormalizedRecord) => 30 - n.source === 'lastfm' && 31 - (n.original.recordingMbId || n.original.releaseMbId || n.original.artists[0]?.artistMbId); 32 - if (hasMb(a) && !hasMb(b)) return a.original; 33 - if (hasMb(b) && !hasMb(a)) return b.original; 34 - return a.source === 'spotify' ? a.original : b.original; 35 - } 36 - 37 - export interface MergeStats { 38 - lastfmTotal: number; 39 - spotifyTotal: number; 40 - duplicatesRemoved: number; 41 - mergedTotal: number; 42 - } 43 - 44 - export function mergePlayRecords( 45 - lastfmRecords: PlayRecord[], 46 - spotifyRecords: PlayRecord[] 47 - ): { merged: PlayRecord[]; stats: MergeStats } { 48 - const toNorm = (r: PlayRecord, source: 'lastfm' | 'spotify'): NormalizedRecord => ({ 49 - original: r, 50 - normalizedTrack: normalizeString(r.trackName), 51 - normalizedArtist: normalizeString(r.artists[0]?.artistName ?? ''), 52 - timestamp: new Date(r.playedTime).getTime(), 53 - source 54 - }); 55 - 56 - const all = [ 57 - ...lastfmRecords.map((r) => toNorm(r, 'lastfm')), 58 - ...spotifyRecords.map((r) => toNorm(r, 'spotify')) 59 - ].sort((a, b) => a.timestamp - b.timestamp); 60 - 61 - const unique: PlayRecord[] = []; 62 - const seen = new Set<string>(); 63 - let dups = 0; 64 - 65 - for (const rec of all) { 66 - const key = `${rec.normalizedTrack}|${rec.normalizedArtist}|${Math.floor(rec.timestamp / 60_000)}`; 67 - if (seen.has(key)) { 68 - const idx = unique.findIndex((u) => { 69 - const n = toNorm(u, u.musicServiceBaseDomain === 'last.fm' ? 'lastfm' : 'spotify'); 70 - return areDuplicates(rec, n); 71 - }); 72 - if (idx !== -1) { 73 - const existing = toNorm(unique[idx], unique[idx].musicServiceBaseDomain === 'last.fm' ? 'lastfm' : 'spotify'); 74 - unique[idx] = betterRecord(existing, rec); 75 - dups++; 76 - continue; 77 - } 78 - } 79 - seen.add(key); 80 - unique.push(rec.original); 81 - } 82 - 83 - unique.sort((a, b) => new Date(a.playedTime).getTime() - new Date(b.playedTime).getTime()); 84 - 85 - return { 86 - merged: unique, 87 - stats: { 88 - lastfmTotal: lastfmRecords.length, 89 - spotifyTotal: spotifyRecords.length, 90 - duplicatesRemoved: dups, 91 - mergedTotal: unique.length 92 - } 93 - }; 94 - } 95 - 96 - /** Remove duplicate records within a single input set (keep first occurrence). */ 97 - export function deduplicateInputRecords(records: PlayRecord[]): { unique: PlayRecord[]; duplicates: number } { 98 - const seen = new Map<string, PlayRecord>(); 99 - let dups = 0; 100 - for (const r of records) { 101 - const key = `${(r.artists[0]?.artistName ?? '').toLowerCase()}|||${r.trackName.toLowerCase()}|||${r.playedTime}`; 102 - if (!seen.has(key)) seen.set(key, r); 103 - else dups++; 104 - } 105 - return { unique: Array.from(seen.values()), duplicates: dups }; 106 - } 107 - 108 - export function sortRecords(records: PlayRecord[], reverseChronological = false): PlayRecord[] { 109 - return [...records].sort((a, b) => { 110 - const diff = new Date(a.playedTime).getTime() - new Date(b.playedTime).getTime(); 111 - return reverseChronological ? -diff : diff; 112 - }); 113 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + export * from '$core/merge.js';
+2 -195
web/src/lib/core/publisher.ts
··· 1 - /** 2 - * Browser-compatible publisher. 3 - * Mirrors src/lib/publisher.ts without Node.js deps. 4 - * Uses in-memory rate limiting and progress callbacks instead of console.log. 5 - */ 6 - 7 - import type { Agent } from '@atproto/api'; 8 - import type { PlayRecord } from '../types.js'; 9 - import { RECORD_TYPE, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from '../config.js'; 10 - import { BrowserRateLimiter } from './rate-limiter.js'; 11 - import { generateTIDFromISO } from './tid.js'; 12 - import { normalizeHeaders, isRateLimitError } from './rate-limit-headers.js'; 13 - 14 - export interface PublishProgress { 15 - batchIndex: number; 16 - totalBatches: number; 17 - recordsProcessed: number; 18 - totalRecords: number; 19 - successCount: number; 20 - errorCount: number; 21 - currentBatchSize: number; 22 - message: string; 23 - } 24 - 25 - export interface PublisherCallbacks { 26 - onProgress: (p: PublishProgress) => void; 27 - onLog: (level: 'info' | 'success' | 'warn' | 'error' | 'progress', msg: string) => void; 28 - isCancelled: () => boolean; 29 - } 30 - 31 - /** Sleep for up to `ms` milliseconds, waking early if `isCancelled()` becomes true. */ 32 - function cancellableSleep(ms: number, isCancelled: () => boolean): Promise<void> { 33 - return new Promise((resolve) => { 34 - const end = Date.now() + ms; 35 - const tick = () => { 36 - if (isCancelled() || Date.now() >= end) { resolve(); return; } 37 - setTimeout(tick, Math.min(50, end - Date.now())); 38 - }; 39 - tick(); 40 - }); 41 - } 42 - 43 - function normalizeResponseHeaders(response: any): Record<string, string> { 44 - const headers: Record<string, string> = {}; 45 - if (response?.headers) { 46 - if (typeof response.headers.forEach === 'function') { 47 - response.headers.forEach((v: string, k: string) => { headers[k] = v; }); 48 - } else { 49 - Object.assign(headers, response.headers); 50 - } 51 - } 52 - return headers; 53 - } 54 - 55 - export async function publishRecords( 56 - agent: Agent, 57 - records: PlayRecord[], 58 - dryRun: boolean, 59 - callbacks: PublisherCallbacks 60 - ): Promise<{ successCount: number; errorCount: number; cancelled: boolean }> { 61 - const { onProgress, onLog, isCancelled } = callbacks; 62 - 63 - // Abort controller so we can cut off any in-flight fetch the instant Stop is pressed. 64 - const ac = new AbortController(); 65 - const cancelPoll = setInterval(() => { if (isCancelled()) ac.abort(); }, 50); 66 - const total = records.length; 67 - 68 - if (dryRun) { 69 - onLog('info', `[DRY RUN] Would publish ${total} records`); 70 - const preview = records.slice(0, 5); 71 - preview.forEach((r, i) => { 72 - onLog('info', ` ${i + 1}. ${r.artists[0]?.artistName} – ${r.trackName} (${r.playedTime.slice(0, 10)})`); 73 - }); 74 - if (total > 5) onLog('info', ` …and ${total - 5} more`); 75 - return { successCount: total, errorCount: 0, cancelled: false }; 76 - } 77 - 78 - const rl = new BrowserRateLimiter({ headroom: 0.15 }); 79 - let currentBatchSize = 50; // probe batch 80 - let currentDelay = 500; 81 - let successCount = 0; 82 - let errorCount = 0; 83 - let batchCounter = 0; 84 - let i = 0; 85 - const startTime = Date.now(); 86 - 87 - onLog('info', `Publishing ${total.toLocaleString()} records to ATProto…`); 88 - onLog('warn', 'Do not close this tab while publishing.'); 89 - 90 - while (i < total) { 91 - if (isCancelled()) { 92 - onLog('warn', 'Import cancelled by user.'); 93 - return { successCount, errorCount, cancelled: true }; 94 - } 95 - 96 - const batch = records.slice(i, Math.min(i + currentBatchSize, total)); 97 - batchCounter++; 98 - const pct = ((i / total) * 100).toFixed(1); 99 - 100 - onProgress({ 101 - batchIndex: batchCounter, 102 - totalBatches: Math.ceil(total / currentBatchSize), 103 - recordsProcessed: i, 104 - totalRecords: total, 105 - successCount, 106 - errorCount, 107 - currentBatchSize: batch.length, 108 - message: `[${pct}%] Batch ${batchCounter} — records ${i + 1}–${Math.min(i + batch.length, total)}` 109 - }); 110 - 111 - const writes = await Promise.all( 112 - batch.map(async (record) => ({ 113 - $type: 'com.atproto.repo.applyWrites#create', 114 - collection: RECORD_TYPE, 115 - rkey: await generateTIDFromISO(record.playedTime, 'web:import'), 116 - value: record 117 - })) 118 - ); 119 - 120 - const batchPoints = batch.length * POINTS_PER_RECORD; 121 - await rl.waitForPermit(batchPoints, isCancelled); 122 - if (isCancelled()) { 123 - clearInterval(cancelPoll); 124 - onLog('warn', 'Import cancelled by user.'); 125 - return { successCount, errorCount, cancelled: true }; 126 - } 127 - 128 - try { 129 - const response = await agent.com.atproto.repo.applyWrites( 130 - { repo: agent.did ?? '', writes: writes as any }, 131 - { signal: ac.signal } 132 - ); 133 - 134 - successCount += response.data.results?.length ?? batch.length; 135 - 136 - // Learn rate limits from response headers 137 - const rawHeaders = normalizeResponseHeaders(response); 138 - if (Object.keys(rawHeaders).length > 0) { 139 - const norm = normalizeHeaders(rawHeaders); 140 - rl.updateFromHeaders(norm); 141 - 142 - // After first response, optimise batch size 143 - if (batchCounter === 1) { 144 - const cap = rl.getServerCapacity(); 145 - if (cap) { 146 - const remaining = rl.getActualRemaining(); 147 - const pointsPerSec = cap.limit / cap.windowSeconds; 148 - const recsPerSec = pointsPerSec / POINTS_PER_RECORD * 0.8; 149 - currentBatchSize = Math.min( 150 - MAX_PDS_BATCH_SIZE, 151 - Math.max(10, Math.floor(recsPerSec * 45)) 152 - ); 153 - currentDelay = Math.max(500, Math.floor((currentBatchSize / recsPerSec) * 1000)); 154 - onLog('info', `📊 Server: ${cap.limit} pts/${cap.windowSeconds}s — optimised to ${currentBatchSize} records/batch`); 155 - onLog('info', ` Remaining quota: ${remaining.toLocaleString()}/${cap.limit.toLocaleString()}`); 156 - } 157 - } 158 - } 159 - 160 - const elapsed = ((Date.now() - startTime) / 1000).toFixed(0); 161 - const rps = (successCount / ((Date.now() - startTime) / 1000)).toFixed(1); 162 - onLog('progress', `✓ Batch ${batchCounter} — ${successCount}/${total} records (${rps} rec/s, ${elapsed}s)`); 163 - 164 - i += batch.length; 165 - } catch (err: any) { 166 - if (ac.signal.aborted || isCancelled()) { 167 - clearInterval(cancelPoll); 168 - onLog('warn', 'Import cancelled by user.'); 169 - return { successCount, errorCount, cancelled: true }; 170 - } 171 - if (isRateLimitError(err)) { 172 - onLog('warn', '⚠️ Rate limit hit — waiting for quota reset…'); 173 - // Extract headers from error if present 174 - const errHeaders = err?.response?.headers ?? err?.headers ?? {}; 175 - if (Object.keys(errHeaders).length > 0) { 176 - rl.updateFromHeaders(normalizeHeaders(errHeaders)); 177 - } 178 - await rl.waitForPermit(batchPoints); 179 - continue; // retry same batch 180 - } 181 - 182 - // Non-retryable error 183 - errorCount += batch.length; 184 - onLog('error', `✗ Batch ${batchCounter} failed: ${err.message ?? err}`); 185 - i += batch.length; 186 - } 187 - 188 - if (i < total) { 189 - await cancellableSleep(currentDelay, isCancelled); 190 - } 191 - } 192 - 193 - clearInterval(cancelPoll); 194 - return { successCount, errorCount, cancelled: false }; 195 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + export * from '$core/publisher.js';
+2 -47
web/src/lib/core/rate-limit-headers.ts
··· 1 - /** 2 - * Minimal rate-limit header parsing — browser-safe. 3 - * Mirrors src/utils/rate-limit-headers.ts without the logger import. 4 - */ 5 - 6 - export interface RateLimitHeaders { 7 - limit?: number; 8 - remaining?: number; 9 - reset?: number; 10 - windowSeconds?: number; 11 - } 12 - 13 - export function normalizeHeaders(headers: Record<string, string>): Record<string, string> { 14 - const out: Record<string, string> = {}; 15 - for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; 16 - return out; 17 - } 18 - 19 - export function parseRateLimitHeaders(headers: Record<string, string>): RateLimitHeaders { 20 - const h = normalizeHeaders(headers); 21 - const get = (k: string) => h[k] ?? h[`x-${k}`] ?? ''; 22 - 23 - const limit = parseInt(get('ratelimit-limit'), 10); 24 - const remaining = parseInt(get('ratelimit-remaining'), 10); 25 - const reset = parseInt(get('ratelimit-reset'), 10); 26 - const policy = get('ratelimit-policy'); 27 - 28 - let windowSeconds: number | undefined; 29 - const m = /;w=(\d+)/.exec(policy); 30 - if (m) windowSeconds = parseInt(m[1], 10); 31 - else if (!isNaN(reset)) { 32 - windowSeconds = Math.max(0, reset - Math.floor(Date.now() / 1000)); 33 - } 34 - 35 - return { 36 - limit: isNaN(limit) ? undefined : limit, 37 - remaining: isNaN(remaining) ? undefined : remaining, 38 - reset: isNaN(reset) ? undefined : reset, 39 - windowSeconds 40 - }; 41 - } 42 - 43 - export function isRateLimitError(err: any): boolean { 44 - if (err?.status === 429) return true; 45 - const msg = (err?.message ?? '').toLowerCase(); 46 - return msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('ratelimit'); 47 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + export * from '$core/rate-limit-headers.js';
+5 -93
web/src/lib/core/rate-limiter.ts
··· 1 - /** 2 - * In-memory rate limiter for browser use. 3 - * Mirrors the interface of src/utils/rate-limiter.ts without any Node.js deps. 4 - */ 5 - 6 - interface State { 7 - limit: number; 8 - remaining: number; 9 - resetAt: number; // unix seconds 10 - windowSeconds: number; 11 - } 12 - 13 - export class BrowserRateLimiter { 14 - private state: State | null = null; 15 - private readonly headroom: number; 16 - 17 - constructor(opts?: { headroom?: number }) { 18 - this.headroom = opts?.headroom ?? 0.15; 19 - } 20 - 21 - updateFromHeaders(headers: Record<string, string>): void { 22 - const h = (k: string) => headers[k.toLowerCase()] ?? headers[k] ?? ''; 23 - const limitStr = h('ratelimit-limit') || h('x-ratelimit-limit'); 24 - const remainingStr = h('ratelimit-remaining') || h('x-ratelimit-remaining'); 25 - const resetStr = h('ratelimit-reset') || h('x-ratelimit-reset'); 26 - const policy = h('ratelimit-policy') || h('x-ratelimit-policy'); 27 - 28 - const limit = parseInt(limitStr, 10); 29 - const remaining = parseInt(remainingStr, 10); 30 - const reset = parseInt(resetStr, 10); 31 - 32 - if (!limit || isNaN(limit) || isNaN(remaining)) return; 33 - 34 - let windowSeconds = 3600; 35 - const m = /;w=(\d+)/.exec(policy); 36 - if (m) windowSeconds = parseInt(m[1], 10); 37 - 38 - const now = Math.floor(Date.now() / 1000); 39 - this.state = { 40 - limit, 41 - remaining, 42 - resetAt: isNaN(reset) ? now + windowSeconds : reset, 43 - windowSeconds 44 - }; 45 - } 46 - 47 - getActualRemaining(): number { 48 - if (!this.state) return 0; 49 - if (Math.floor(Date.now() / 1000) >= this.state.resetAt) return this.state.limit; 50 - return this.state.remaining; 51 - } 52 - 53 - getServerCapacity(): { limit: number; windowSeconds: number } | null { 54 - if (!this.state || this.state.limit === 0) return null; 55 - return { limit: this.state.limit, windowSeconds: this.state.windowSeconds }; 56 - } 57 - 58 - hasServerInfo(): boolean { 59 - return this.state !== null && this.state.limit > 0; 60 - } 61 - 62 - async waitForPermit(pointsNeeded: number, isCancelled?: () => boolean): Promise<void> { 63 - if (!this.state) return; // no state yet — let first request probe 64 - 65 - const now = Math.floor(Date.now() / 1000); 66 - if (now >= this.state.resetAt) { 67 - this.state.remaining = this.state.limit; 68 - this.state.resetAt = now + this.state.windowSeconds; 69 - } 70 - 71 - const headroomPts = Math.floor(this.state.limit * this.headroom); 72 - const effective = this.state.remaining - headroomPts; 73 - 74 - if (effective < pointsNeeded) { 75 - // Wait until the window resets, but poll for cancellation every 50 ms. 76 - const resetMs = Math.max(0, (this.state.resetAt - Math.floor(Date.now() / 1000)) + 1) * 1000; 77 - const end = Date.now() + resetMs; 78 - await new Promise<void>((resolve) => { 79 - const tick = () => { 80 - if ((isCancelled?.()) || Date.now() >= end) { resolve(); return; } 81 - setTimeout(tick, Math.min(50, end - Date.now())); 82 - }; 83 - tick(); 84 - }); 85 - if (this.state && !isCancelled?.()) { 86 - this.state.remaining = this.state.limit; 87 - this.state.resetAt = Math.floor(Date.now() / 1000) + this.state.windowSeconds; 88 - } 89 - } 90 - 91 - if (this.state) this.state.remaining = Math.max(0, this.state.remaining - pointsNeeded); 92 - } 93 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + // RateLimiter covers both Node and browser (globalThis.crypto works in both). 3 + export * from '$core/rate-limiter.js'; 4 + // Backwards-compatible alias for any code that referenced BrowserRateLimiter. 5 + export { RateLimiter as BrowserRateLimiter } from '$core/rate-limiter.js';
+6 -35
web/src/lib/core/spotify.ts
··· 1 1 /** 2 - * Browser-compatible Spotify JSON parser. 3 - * Mirrors src/lib/spotify.ts without any Node.js deps. 2 + * Spotify JSON — web layer. 3 + * Re-exports the shared core logic and adds a browser File API loader. 4 4 */ 5 + import type { SpotifyRecord } from '$core/types.js'; 6 + import { parseSpotifyJsonContent, convertSpotifyToPlayRecord } from '$core/spotify.js'; 5 7 6 - import type { SpotifyRecord, PlayRecord } from '../types.js'; 7 - import { RECORD_TYPE, CLIENT_AGENT } from '../config.js'; 8 + export { parseSpotifyJsonContent, convertSpotifyToPlayRecord }; 8 9 9 - export function parseSpotifyJsonContent(records: SpotifyRecord[]): SpotifyRecord[] { 10 - return records.filter( 11 - (r) => r.master_metadata_track_name && r.master_metadata_album_artist_name 12 - ); 13 - } 14 - 10 + /** Read one or more browser File objects and parse them as Spotify JSON exports. */ 15 11 export async function parseSpotifyFiles(files: File[]): Promise<SpotifyRecord[]> { 16 12 let all: SpotifyRecord[] = []; 17 13 for (const file of files) { ··· 21 17 } 22 18 return parseSpotifyJsonContent(all); 23 19 } 24 - 25 - export function convertSpotifyToPlayRecord(r: SpotifyRecord): PlayRecord { 26 - const artists: PlayRecord['artists'] = []; 27 - if (r.master_metadata_album_artist_name) { 28 - artists.push({ artistName: r.master_metadata_album_artist_name }); 29 - } 30 - 31 - const record: PlayRecord = { 32 - $type: RECORD_TYPE, 33 - trackName: r.master_metadata_track_name ?? 'Unknown Track', 34 - artists, 35 - playedTime: r.ts, 36 - submissionClientAgent: CLIENT_AGENT, 37 - musicServiceBaseDomain: 'spotify.com', 38 - originUrl: '' 39 - }; 40 - 41 - if (r.master_metadata_album_album_name) record.releaseName = r.master_metadata_album_album_name; 42 - if (r.spotify_track_uri) { 43 - const id = r.spotify_track_uri.replace('spotify:track:', ''); 44 - record.originUrl = `https://open.spotify.com/track/${id}`; 45 - } 46 - 47 - return record; 48 - }
+2 -131
web/src/lib/core/sync.ts
··· 1 - /** 2 - * Browser-compatible sync helpers. 3 - * Fetches existing records via com.atproto.sync.getRepo (CAR export) — 4 - * sync namespace, separate rate-limit envelope, zero AppView quota cost. 5 - */ 6 - 7 - import type { Agent } from '@atproto/api'; 8 - import type { PlayRecord } from '../types.js'; 9 - import { RECORD_TYPE } from '../config.js'; 10 - import { fetchRepoViaCAR, getPdsUrlFromAgent } from './car-fetch.js'; 11 - 12 - export interface ExistingRecord { 13 - uri: string; 14 - cid: string; 15 - value: PlayRecord; 16 - } 17 - 18 - function recordKey(r: PlayRecord): string { 19 - const artist = (r.artists[0]?.artistName ?? '').toLowerCase().trim(); 20 - return `${artist}|||${r.trackName.toLowerCase().trim()}|||${r.playedTime}`; 21 - } 22 - 23 - /** In-session cache so repeat calls within the same page session don't re-fetch. */ 24 - const sessionCache = new Map<string, Map<string, ExistingRecord>>(); 25 - 26 - export async function fetchExistingRecords( 27 - agent: Agent, 28 - onProgress?: (fetched: number) => void, 29 - forceRefresh = false, 30 - signal?: AbortSignal 31 - ): Promise<Map<string, ExistingRecord>> { 32 - const did = agent.did; 33 - if (!did) throw new Error('No authenticated session'); 34 - 35 - if (!forceRefresh && sessionCache.has(did)) { 36 - return sessionCache.get(did)!; 37 - } 38 - 39 - signal?.throwIfAborted(); 40 - 41 - const pdsUrl = getPdsUrlFromAgent(agent); 42 - const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal); 43 - 44 - const map = new Map<string, ExistingRecord>(); 45 - for (const rec of carRecords) { 46 - const value = rec.value as unknown as PlayRecord; 47 - map.set(recordKey(value), { uri: rec.uri, cid: rec.cid, value }); 48 - } 49 - 50 - onProgress?.(map.size); 51 - sessionCache.set(did, map); 52 - return map; 53 - } 54 - 55 - export function filterNewRecords( 56 - records: PlayRecord[], 57 - existing: Map<string, ExistingRecord> 58 - ): PlayRecord[] { 59 - return records.filter((r) => !existing.has(recordKey(r))); 60 - } 61 - 62 - export async function fetchAllRecordsForDedup( 63 - agent: Agent, 64 - onProgress?: (fetched: number) => void, 65 - signal?: AbortSignal 66 - ): Promise<ExistingRecord[]> { 67 - const did = agent.did; 68 - if (!did) throw new Error('No authenticated session'); 69 - 70 - signal?.throwIfAborted(); 71 - 72 - const pdsUrl = getPdsUrlFromAgent(agent); 73 - const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal); 74 - 75 - const all: ExistingRecord[] = carRecords.map((rec) => ({ 76 - uri: rec.uri, 77 - cid: rec.cid, 78 - value: rec.value as unknown as PlayRecord, 79 - })); 80 - 81 - onProgress?.(all.length); 82 - return all; 83 - } 84 - 85 - export interface DedupGroup { 86 - key: string; 87 - records: ExistingRecord[]; 88 - } 89 - 90 - export function findDuplicateGroups(records: ExistingRecord[]): DedupGroup[] { 91 - const groups = new Map<string, ExistingRecord[]>(); 92 - for (const rec of records) { 93 - const key = recordKey(rec.value); 94 - if (!groups.has(key)) groups.set(key, []); 95 - groups.get(key)!.push(rec); 96 - } 97 - const result: DedupGroup[] = []; 98 - for (const [key, recs] of groups) { 99 - if (recs.length > 1) result.push({ key, records: recs }); 100 - } 101 - return result; 102 - } 103 - 104 - export async function removeDuplicateRecords( 105 - agent: Agent, 106 - groups: DedupGroup[], 107 - onProgress?: (removed: number) => void, 108 - signal?: AbortSignal 109 - ): Promise<number> { 110 - let removed = 0; 111 - for (const group of groups) { 112 - for (const rec of group.records.slice(1)) { 113 - signal?.throwIfAborted(); 114 - try { 115 - await agent.com.atproto.repo.deleteRecord( 116 - { repo: agent.did ?? '', collection: RECORD_TYPE, rkey: rec.uri.split('/').pop()! }, 117 - { signal } 118 - ); 119 - removed++; 120 - onProgress?.(removed); 121 - await new Promise<void>((resolve, reject) => { 122 - const t = setTimeout(resolve, 100); 123 - signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); }, { once: true }); 124 - }); 125 - } catch (err: any) { 126 - if (signal?.aborted) throw err; 127 - } 128 - } 129 - } 130 - return removed; 131 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + export * from '$core/sync.js';
+4 -48
web/src/lib/core/tid.ts
··· 1 - /** 2 - * Browser-compatible TID (Timestamp Identifier) generation for ATProto. 3 - * 4 - * Re-implements the monotonic TID clock from src/utils/tid-clock.ts without 5 - * any Node.js dependencies (no fs, no crypto module — uses crypto.getRandomValues). 6 - * 7 - * Spec: https://atproto.com/specs/tid 8 - */ 9 - 10 - // Base-32 alphabet used by AT Protocol (not standard base32) 11 - const S32_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; 12 - 13 - function s32encode(n: number): string { 14 - if (n === 0) return '2'; 15 - let s = ''; 16 - let val = n; 17 - while (val > 0) { 18 - s = S32_CHARS[val % 32] + s; 19 - val = Math.floor(val / 32); 20 - } 21 - return s; 22 - } 23 - 24 - // In-memory monotonic state 25 - let lastTimestampUs = 0; 26 - const clockId = (() => { 27 - const buf = new Uint8Array(1); 28 - crypto.getRandomValues(buf); 29 - return buf[0] % 32; 30 - })(); 31 - 32 - export async function generateTIDFromISO(isoString: string, _context?: string): Promise<string> { 33 - let timestamp = new Date(isoString).getTime() * 1000; // ms → µs 34 - 35 - // Monotonicity: never go backwards 36 - if (timestamp <= lastTimestampUs) { 37 - timestamp = lastTimestampUs + 1; 38 - } 39 - lastTimestampUs = timestamp; 40 - 41 - const timestampStr = s32encode(timestamp).padStart(11, '2'); 42 - const clockIdStr = s32encode(clockId).padStart(2, '2'); 43 - return timestampStr + clockIdStr; 44 - } 45 - 46 - export function resetTidClock(): void { 47 - lastTimestampUs = 0; 48 - } 1 + // Shared implementation lives in src/core/ — no duplication. 2 + // src/core/tid.ts already uses globalThis.crypto which works in both Node 20+ 3 + // and browsers, so no browser-specific shim is needed. 4 + export * from '$core/tid.js';
+13 -68
web/src/lib/types.ts
··· 1 - // Shared type definitions for the Malachite web app. 2 - // These mirror src/types.ts but are free of Node.js dependencies. 3 - 4 - export interface LastFmCsvRecord { 5 - uts: string; 6 - utc_time: string; 7 - artist: string; 8 - artist_mbid?: string; 9 - album: string; 10 - album_mbid?: string; 11 - track: string; 12 - track_mbid?: string; 13 - } 14 - 15 - export interface PlayRecordArtist { 16 - artistName: string; 17 - artistMbId?: string; 18 - } 19 - 20 - export interface PlayRecord { 21 - $type: string; 22 - trackName: string; 23 - artists: PlayRecordArtist[]; 24 - playedTime: string; 25 - submissionClientAgent: string; 26 - musicServiceBaseDomain: string; 27 - releaseName?: string; 28 - releaseMbId?: string; 29 - recordingMbId?: string; 30 - originUrl: string; 31 - } 32 - 33 - export interface PublishResult { 34 - successCount: number; 35 - errorCount: number; 36 - cancelled: boolean; 37 - } 38 - 39 - export type ImportMode = 'lastfm' | 'spotify' | 'combined' | 'sync' | 'deduplicate'; 40 - 41 - export interface SpotifyRecord { 42 - ts: string; 43 - platform: string; 44 - ms_played: number; 45 - conn_country: string; 46 - master_metadata_track_name: string | null; 47 - master_metadata_album_artist_name: string | null; 48 - master_metadata_album_album_name: string | null; 49 - spotify_track_uri: string | null; 50 - episode_name: string | null; 51 - episode_show_name: string | null; 52 - spotify_episode_uri: string | null; 53 - reason_start: string; 54 - reason_end: string; 55 - shuffle: boolean; 56 - skipped: boolean; 57 - offline: boolean; 58 - offline_timestamp: number | null; 59 - incognito_mode: boolean; 60 - } 61 - 62 - export type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'progress' | 'section'; 63 - 64 - export interface LogEntry { 65 - level: LogLevel; 66 - message: string; 67 - timestamp: number; 68 - } 1 + // All shared types live in src/core/types.ts — single source of truth. 2 + // Re-export everything from there so the rest of the web app can import 3 + // from '$lib/types.js' as before without any path changes. 4 + export type { 5 + LastFmCsvRecord, 6 + PlayRecordArtist, 7 + PlayRecord, 8 + PublishResult, 9 + ImportMode, 10 + SpotifyRecord, 11 + LogLevel, 12 + LogEntry, 13 + } from '$core/types.js';
+15 -1
web/svelte.config.js
··· 1 1 import adapter from '@sveltejs/adapter-vercel'; 2 + import { fileURLToPath } from 'url'; 3 + import { dirname, resolve } from 'path'; 4 + 5 + const __dirname = dirname(fileURLToPath(import.meta.url)); 2 6 3 7 /** @type {import('@sveltejs/kit').Config} */ 4 - const config = { kit: { adapter: adapter() } }; 8 + const config = { 9 + kit: { 10 + adapter: adapter(), 11 + alias: { 12 + // Shared, environment-agnostic core — single source of truth. 13 + // CLI uses src/core/ directly; web imports via this alias so there is 14 + // no duplicated logic. 15 + $core: resolve(__dirname, '../src/core'), 16 + }, 17 + }, 18 + }; 5 19 6 20 export default config;
+11 -5
web/vite.config.ts
··· 9 9 10 10 export default defineConfig({ 11 11 plugins: [tailwindcss(), sveltekit()], 12 - // Pin dev server to 127.0.0.1 — the OAuth loopback redirect_uri in 13 - // src/lib/core/oauth.ts must use the same origin (RFC 8252 §7.3). 12 + 14 13 server: { 15 14 host: '127.0.0.1', 16 15 port: 5173, 16 + fs: { 17 + allow: [ 18 + resolve('..') // allow workspace root 19 + ] 20 + } 17 21 }, 18 - // Ensure Buffer / global are polyfilled for @atproto/api in the browser 22 + 19 23 define: { 20 24 global: 'globalThis', 21 25 __WEB_VERSION__: JSON.stringify(webPkg.version), 22 26 __CLI_VERSION__: JSON.stringify(cliPkg.version) 23 27 }, 28 + 24 29 optimizeDeps: { 25 30 include: ['@atproto/api', '@atproto/common-web'] 26 31 }, 32 + 27 33 build: { 28 34 target: 'es2022', 29 - chunkSizeWarningLimit: 1000 // @atproto/oauth-client-browser is large but unsplittable 35 + chunkSizeWarningLimit: 1000 30 36 } 31 - }); 37 + });