From d3e250e361afb1d953bd2957fd7e91b5bf098aad Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 28 Mar 2026 23:20:40 -0500 Subject: [PATCH] Initial commit: Second Brain Platform Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose --- .claude/settings.local.json | 17 + .env.example | 12 + .gitignore | 18 + docker-compose.yml | 144 + docs/architecture.md | 39 + frontend-v2/.dockerignore | 4 + frontend-v2/.gitignore | 23 + frontend-v2/.npmrc | 1 + frontend-v2/.vscode/extensions.json | 3 + frontend-v2/DESIGN_SYSTEM.md | 324 + frontend-v2/Dockerfile | 15 + frontend-v2/README.md | 42 + frontend-v2/package-lock.json | 2332 +++++++ frontend-v2/package.json | 31 + frontend-v2/src/app.css | 461 ++ frontend-v2/src/app.d.ts | 13 + frontend-v2/src/app.html | 11 + frontend-v2/src/hooks.server.ts | 194 + frontend-v2/src/lib/api/client.ts | 78 + frontend-v2/src/lib/assets/favicon.svg | 1 + .../components/dashboard/BudgetModule.svelte | 114 + .../dashboard/DashboardActionCard.svelte | 157 + .../components/dashboard/FitnessModule.svelte | 199 + .../components/dashboard/IssuesModule.svelte | 113 + .../components/layout/CommandPalette.svelte | 187 + .../lib/components/layout/MobileTabBar.svelte | 165 + .../src/lib/components/layout/Navbar.svelte | 215 + .../lib/components/media/BookLibrary.svelte | 362 ++ .../lib/components/media/BookSearch.svelte | 434 ++ .../lib/components/media/MusicSearch.svelte | 276 + .../lib/components/shared/ActionCard.svelte | 161 + .../lib/components/shared/EmptyState.svelte | 61 + .../lib/components/shared/ImmichPicker.svelte | 250 + .../lib/components/shared/ModuleCard.svelte | 69 + .../lib/components/shared/PageHeader.svelte | 47 + .../src/lib/components/shared/PageTabs.svelte | 76 + .../lib/components/shared/SearchBar.svelte | 66 + .../lib/components/shared/SkeletonRow.svelte | 93 + .../src/lib/components/shared/Toast.svelte | 95 + .../components/shared/TransactionRow.svelte | 148 + .../components/trips/CreateTripModal.svelte | 101 + .../lib/components/trips/ImageUpload.svelte | 274 + .../src/lib/components/trips/ItemModal.svelte | 368 ++ .../trips/PlacesAutocomplete.svelte | 95 + .../lib/components/trips/TripEditModal.svelte | 193 + frontend-v2/src/lib/index.ts | 1 + frontend-v2/src/lib/stores/theme.svelte.ts | 26 + frontend-v2/src/lib/utils.ts | 6 + .../src/routes/(app)/+layout.server.ts | 28 + frontend-v2/src/routes/(app)/+layout.svelte | 58 + frontend-v2/src/routes/(app)/+page.svelte | 127 + .../src/routes/(app)/budget/+page.svelte | 763 +++ .../src/routes/(app)/fitness/+page.svelte | 1605 +++++ .../routes/(app)/fitness/foods/+page.svelte | 149 + .../routes/(app)/fitness/goals/+page.svelte | 157 + .../(app)/fitness/templates/+page.svelte | 136 + .../src/routes/(app)/inventory/+page.svelte | 902 +++ .../routes/(app)/inventory/item/+page.svelte | 170 + .../src/routes/(app)/media/+page.svelte | 75 + .../src/routes/(app)/reader/+page.svelte | 880 +++ .../src/routes/(app)/settings/+page.svelte | 328 + .../src/routes/(app)/trips/+page.svelte | 368 ++ .../src/routes/(app)/trips/trip/+page.svelte | 1041 ++++ frontend-v2/src/routes/(auth)/+layout.svelte | 9 + .../src/routes/(auth)/login/+page.svelte | 128 + frontend-v2/src/routes/+layout.svelte | 20 + frontend-v2/static/robots.txt | 3 + frontend-v2/svelte.config.js | 14 + frontend-v2/tsconfig.json | 20 + frontend-v2/vite.config.ts | 10 + gateway/Dockerfile | 5 + gateway/server.py | 1878 ++++++ package-lock.json | 2658 ++++++++ package.json | 17 + packages/api-utils/package.json | 7 + packages/ui-components/package.json | 6 + services/budget/Dockerfile | 14 + services/budget/package-lock.json | 1443 +++++ services/budget/package.json | 14 + services/budget/server.js | 653 ++ services/fitness/Dockerfile.backend | 5 + services/fitness/docker-compose.yml | 28 + services/fitness/frontend-legacy/Dockerfile | 16 + .../fitness/frontend-legacy/package-lock.json | 2320 +++++++ services/fitness/frontend-legacy/package.json | 28 + services/fitness/frontend-legacy/src/app.css | 25 + services/fitness/frontend-legacy/src/app.d.ts | 5 + services/fitness/frontend-legacy/src/app.html | 12 + .../frontend-legacy/src/hooks.server.ts | 39 + .../frontend-legacy/src/lib/api/client.ts | 81 + .../frontend-legacy/src/lib/api/types.ts | 165 + .../src/lib/components/AddFoodModal.svelte | 270 + .../src/lib/components/MacroBar.svelte | 23 + .../src/lib/components/Navbar.svelte | 84 + .../frontend-legacy/src/routes/+layout.svelte | 37 + .../frontend-legacy/src/routes/+page.svelte | 297 + .../src/routes/admin/+page.svelte | 171 + .../src/routes/foods/+page.svelte | 338 ++ .../src/routes/goals/+page.svelte | 136 + .../src/routes/login/+page.svelte | 65 + .../src/routes/templates/+page.svelte | 101 + .../fitness/frontend-legacy/svelte.config.js | 14 + .../fitness/frontend-legacy/tsconfig.json | 15 + .../fitness/frontend-legacy/vite.config.ts | 19 + services/fitness/server.py | 2695 +++++++++ services/inventory/Dockerfile | 18 + services/inventory/package.json | 19 + services/inventory/server.js | 2137 +++++++ services/trips/Dockerfile | 11 + services/trips/docker-compose.yml | 29 + services/trips/email-worker/README.md | 115 + services/trips/email-worker/worker.js | 325 + services/trips/email-worker/wrangler.toml | 10 + services/trips/frontend-legacy/.dockerignore | 3 + services/trips/frontend-legacy/.gitignore | 23 + services/trips/frontend-legacy/.npmrc | 1 + .../frontend-legacy/.vscode/extensions.json | 3 + services/trips/frontend-legacy/Dockerfile | 16 + services/trips/frontend-legacy/README.md | 42 + .../trips/frontend-legacy/package-lock.json | 2320 +++++++ services/trips/frontend-legacy/package.json | 29 + services/trips/frontend-legacy/src/app.css | 29 + services/trips/frontend-legacy/src/app.d.ts | 13 + services/trips/frontend-legacy/src/app.html | 13 + .../trips/frontend-legacy/src/hooks.server.ts | 70 + .../frontend-legacy/src/lib/api/client.ts | 53 + .../frontend-legacy/src/lib/api/types.ts | 109 + .../src/lib/assets/favicon.svg | 1 + .../src/lib/components/AIGuideModal.svelte | 114 + .../src/lib/components/DocumentUpload.svelte | 112 + .../src/lib/components/ImageUpload.svelte | 292 + .../src/lib/components/ImmichPicker.svelte | 192 + .../src/lib/components/LocationModal.svelte | 260 + .../src/lib/components/LodgingModal.svelte | 199 + .../src/lib/components/MapsButton.svelte | 80 + .../src/lib/components/Navbar.svelte | 61 + .../src/lib/components/NoteModal.svelte | 127 + .../src/lib/components/ParseModal.svelte | 272 + .../lib/components/PlacesAutocomplete.svelte | 106 + .../src/lib/components/StatsBar.svelte | 306 + .../src/lib/components/TransportModal.svelte | 240 + .../src/lib/components/TripCard.svelte | 92 + .../src/lib/components/TripEditModal.svelte | 185 + .../src/lib/components/TripMap.svelte | 116 + .../trips/frontend-legacy/src/lib/index.ts | 1 + .../frontend-legacy/src/routes/+layout.svelte | 17 + .../frontend-legacy/src/routes/+page.svelte | 627 ++ .../src/routes/login/+page.svelte | 82 + .../src/routes/settings/+page.svelte | 24 + .../src/routes/trip/[id]/+page.svelte | 751 +++ .../src/routes/view/[token]/+page.svelte | 264 + .../trips/frontend-legacy/static/robots.txt | 3 + .../trips/frontend-legacy/svelte.config.js | 14 + services/trips/frontend-legacy/tsconfig.json | 20 + services/trips/frontend-legacy/vite.config.ts | 19 + services/trips/manifest.json | 33 + services/trips/server.py | 5356 +++++++++++++++++ services/trips/sw.js | 649 ++ test-results/.last-run.json | 4 + 159 files changed, 44797 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 frontend-v2/.dockerignore create mode 100644 frontend-v2/.gitignore create mode 100644 frontend-v2/.npmrc create mode 100644 frontend-v2/.vscode/extensions.json create mode 100644 frontend-v2/DESIGN_SYSTEM.md create mode 100644 frontend-v2/Dockerfile create mode 100644 frontend-v2/README.md create mode 100644 frontend-v2/package-lock.json create mode 100644 frontend-v2/package.json create mode 100644 frontend-v2/src/app.css create mode 100644 frontend-v2/src/app.d.ts create mode 100644 frontend-v2/src/app.html create mode 100644 frontend-v2/src/hooks.server.ts create mode 100644 frontend-v2/src/lib/api/client.ts create mode 100644 frontend-v2/src/lib/assets/favicon.svg create mode 100644 frontend-v2/src/lib/components/dashboard/BudgetModule.svelte create mode 100644 frontend-v2/src/lib/components/dashboard/DashboardActionCard.svelte create mode 100644 frontend-v2/src/lib/components/dashboard/FitnessModule.svelte create mode 100644 frontend-v2/src/lib/components/dashboard/IssuesModule.svelte create mode 100644 frontend-v2/src/lib/components/layout/CommandPalette.svelte create mode 100644 frontend-v2/src/lib/components/layout/MobileTabBar.svelte create mode 100644 frontend-v2/src/lib/components/layout/Navbar.svelte create mode 100644 frontend-v2/src/lib/components/media/BookLibrary.svelte create mode 100644 frontend-v2/src/lib/components/media/BookSearch.svelte create mode 100644 frontend-v2/src/lib/components/media/MusicSearch.svelte create mode 100644 frontend-v2/src/lib/components/shared/ActionCard.svelte create mode 100644 frontend-v2/src/lib/components/shared/EmptyState.svelte create mode 100644 frontend-v2/src/lib/components/shared/ImmichPicker.svelte create mode 100644 frontend-v2/src/lib/components/shared/ModuleCard.svelte create mode 100644 frontend-v2/src/lib/components/shared/PageHeader.svelte create mode 100644 frontend-v2/src/lib/components/shared/PageTabs.svelte create mode 100644 frontend-v2/src/lib/components/shared/SearchBar.svelte create mode 100644 frontend-v2/src/lib/components/shared/SkeletonRow.svelte create mode 100644 frontend-v2/src/lib/components/shared/Toast.svelte create mode 100644 frontend-v2/src/lib/components/shared/TransactionRow.svelte create mode 100644 frontend-v2/src/lib/components/trips/CreateTripModal.svelte create mode 100644 frontend-v2/src/lib/components/trips/ImageUpload.svelte create mode 100644 frontend-v2/src/lib/components/trips/ItemModal.svelte create mode 100644 frontend-v2/src/lib/components/trips/PlacesAutocomplete.svelte create mode 100644 frontend-v2/src/lib/components/trips/TripEditModal.svelte create mode 100644 frontend-v2/src/lib/index.ts create mode 100644 frontend-v2/src/lib/stores/theme.svelte.ts create mode 100644 frontend-v2/src/lib/utils.ts create mode 100644 frontend-v2/src/routes/(app)/+layout.server.ts create mode 100644 frontend-v2/src/routes/(app)/+layout.svelte create mode 100644 frontend-v2/src/routes/(app)/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/budget/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/fitness/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/fitness/foods/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/fitness/goals/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/fitness/templates/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/inventory/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/inventory/item/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/media/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/reader/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/settings/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/trips/+page.svelte create mode 100644 frontend-v2/src/routes/(app)/trips/trip/+page.svelte create mode 100644 frontend-v2/src/routes/(auth)/+layout.svelte create mode 100644 frontend-v2/src/routes/(auth)/login/+page.svelte create mode 100644 frontend-v2/src/routes/+layout.svelte create mode 100644 frontend-v2/static/robots.txt create mode 100644 frontend-v2/svelte.config.js create mode 100644 frontend-v2/tsconfig.json create mode 100644 frontend-v2/vite.config.ts create mode 100644 gateway/Dockerfile create mode 100644 gateway/server.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/api-utils/package.json create mode 100644 packages/ui-components/package.json create mode 100644 services/budget/Dockerfile create mode 100644 services/budget/package-lock.json create mode 100644 services/budget/package.json create mode 100644 services/budget/server.js create mode 100644 services/fitness/Dockerfile.backend create mode 100644 services/fitness/docker-compose.yml create mode 100644 services/fitness/frontend-legacy/Dockerfile create mode 100644 services/fitness/frontend-legacy/package-lock.json create mode 100644 services/fitness/frontend-legacy/package.json create mode 100644 services/fitness/frontend-legacy/src/app.css create mode 100644 services/fitness/frontend-legacy/src/app.d.ts create mode 100644 services/fitness/frontend-legacy/src/app.html create mode 100644 services/fitness/frontend-legacy/src/hooks.server.ts create mode 100644 services/fitness/frontend-legacy/src/lib/api/client.ts create mode 100644 services/fitness/frontend-legacy/src/lib/api/types.ts create mode 100644 services/fitness/frontend-legacy/src/lib/components/AddFoodModal.svelte create mode 100644 services/fitness/frontend-legacy/src/lib/components/MacroBar.svelte create mode 100644 services/fitness/frontend-legacy/src/lib/components/Navbar.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/+layout.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/+page.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/admin/+page.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/foods/+page.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/goals/+page.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/login/+page.svelte create mode 100644 services/fitness/frontend-legacy/src/routes/templates/+page.svelte create mode 100644 services/fitness/frontend-legacy/svelte.config.js create mode 100644 services/fitness/frontend-legacy/tsconfig.json create mode 100644 services/fitness/frontend-legacy/vite.config.ts create mode 100644 services/fitness/server.py create mode 100644 services/inventory/Dockerfile create mode 100755 services/inventory/package.json create mode 100755 services/inventory/server.js create mode 100644 services/trips/Dockerfile create mode 100644 services/trips/docker-compose.yml create mode 100644 services/trips/email-worker/README.md create mode 100644 services/trips/email-worker/worker.js create mode 100644 services/trips/email-worker/wrangler.toml create mode 100644 services/trips/frontend-legacy/.dockerignore create mode 100644 services/trips/frontend-legacy/.gitignore create mode 100644 services/trips/frontend-legacy/.npmrc create mode 100644 services/trips/frontend-legacy/.vscode/extensions.json create mode 100644 services/trips/frontend-legacy/Dockerfile create mode 100644 services/trips/frontend-legacy/README.md create mode 100644 services/trips/frontend-legacy/package-lock.json create mode 100644 services/trips/frontend-legacy/package.json create mode 100644 services/trips/frontend-legacy/src/app.css create mode 100644 services/trips/frontend-legacy/src/app.d.ts create mode 100644 services/trips/frontend-legacy/src/app.html create mode 100644 services/trips/frontend-legacy/src/hooks.server.ts create mode 100644 services/trips/frontend-legacy/src/lib/api/client.ts create mode 100644 services/trips/frontend-legacy/src/lib/api/types.ts create mode 100644 services/trips/frontend-legacy/src/lib/assets/favicon.svg create mode 100644 services/trips/frontend-legacy/src/lib/components/AIGuideModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/DocumentUpload.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/ImageUpload.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/ImmichPicker.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/LocationModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/LodgingModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/MapsButton.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/Navbar.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/NoteModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/ParseModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/PlacesAutocomplete.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/StatsBar.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/TransportModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/TripCard.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/TripEditModal.svelte create mode 100644 services/trips/frontend-legacy/src/lib/components/TripMap.svelte create mode 100644 services/trips/frontend-legacy/src/lib/index.ts create mode 100644 services/trips/frontend-legacy/src/routes/+layout.svelte create mode 100644 services/trips/frontend-legacy/src/routes/+page.svelte create mode 100644 services/trips/frontend-legacy/src/routes/login/+page.svelte create mode 100644 services/trips/frontend-legacy/src/routes/settings/+page.svelte create mode 100644 services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte create mode 100644 services/trips/frontend-legacy/src/routes/view/[token]/+page.svelte create mode 100644 services/trips/frontend-legacy/static/robots.txt create mode 100644 services/trips/frontend-legacy/svelte.config.js create mode 100644 services/trips/frontend-legacy/tsconfig.json create mode 100644 services/trips/frontend-legacy/vite.config.ts create mode 100644 services/trips/manifest.json create mode 100644 services/trips/server.py create mode 100644 services/trips/sw.js create mode 100644 test-results/.last-run.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5698a16 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(npx playwright:*)", + "Bash(find /media/yusiboyz/Media/Scripts/platform/frontend -name *.ts -o -name *.js -o -name *.svelte)", + "Bash(curl:*)", + "Bash(sudo chown:*)", + "Bash(node:*)" + ] + }, + "mcpServers": { + "shadcn-svelte": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://shadcn-svelte.mastra.cloud/api/mcp/shadcn/mcp"] + } + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8449eb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +TZ=America/Chicago + +PLATFORM_ORIGIN=http://localhost:3000 + +TRIPS_BACKEND_URL=http://trips-service:8087 +TRIPS_FRONTEND_LEGACY_URL=http://trips-legacy-frontend:3000 +TRIPS_API_TOKEN_KEY=api_token + +FITNESS_BACKEND_URL=http://fitness-service:8095 +FITNESS_FRONTEND_LEGACY_URL=http://fitness-legacy-frontend:3000 +FITNESS_API_TOKEN_KEY=session_token + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..703dfeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +.svelte-kit/ +build/ +.env +*.db +*.db-journal +*.db-wal +data/ +__pycache__/ +*.pyc +.DS_Store +services/fitness/data/ +services/trips/data/ +gateway/data/ +frontend-v2/.svelte-kit/ +frontend-v2/build/ +frontend-v2/node_modules/ +*.png diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a7ca969 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,144 @@ +services: + platform-frontend-v2: + build: + context: ./frontend-v2 + dockerfile: Dockerfile + container_name: platform-frontend-v2 + restart: unless-stopped + ports: + - "3211:3000" + environment: + - ORIGIN=${PLATFORM_V2_ORIGIN:-http://localhost:3211} + - GATEWAY_URL=http://gateway:8100 + - IMMICH_URL=${IMMICH_URL} + - IMMICH_API_KEY=${IMMICH_API_KEY} + - KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005} + - KARAKEEP_API_KEY=${KARAKEEP_API_KEY} + - BODY_SIZE_LIMIT=52428800 + - TZ=${TZ:-America/Chicago} + depends_on: + - gateway + + gateway: + build: + context: ./gateway + dockerfile: Dockerfile + container_name: platform-gateway + restart: unless-stopped + volumes: + - ./gateway/data:/app/data + - /media/yusiboyz/Media/Scripts/booklore/booklore/books:/booklore-books:ro + - /media/yusiboyz/Media/Scripts/shelfmark/books:/bookdrop:ro + environment: + - PORT=8100 + - TRIPS_BACKEND_URL=http://trips-service:8087 + - FITNESS_BACKEND_URL=http://fitness-service:8095 + - INVENTORY_BACKEND_URL=http://inventory-service:3000 + - MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080} + - MINIFLUX_API_KEY=${MINIFLUX_API_KEY} + - TRIPS_API_TOKEN=${TRIPS_API_TOKEN} + - NOCODB_API_TOKEN=${NOCODB_API_TOKEN} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_MODEL=${OPENAI_MODEL:-gpt-5.2} + - SHELFMARK_URL=${SHELFMARK_URL:-http://shelfmark:8084} + - BOOKLORE_URL=${BOOKLORE_URL:-http://booklore:6060} + - BOOKLORE_USER=${BOOKLORE_USER} + - BOOKLORE_PASS=${BOOKLORE_PASS} + - KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005} + - KARAKEEP_API_KEY=${KARAKEEP_API_KEY} + - SPOTIZERR_URL=${SPOTIZERR_URL:-http://spotizerr-app:7171} + - BUDGET_BACKEND_URL=http://budget-service:3001 + - QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42} + - QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080} + - QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin} + - QBITTORRENT_PASSWORD=${QBITTORRENT_PASSWORD} + - SMTP2GO_API_KEY=${SMTP2GO_API_KEY} + - SMTP2GO_FROM_EMAIL=${SMTP2GO_FROM_EMAIL} + - SMTP2GO_FROM_NAME=${SMTP2GO_FROM_NAME:-Platform} + - KINDLE_EMAIL_1=${KINDLE_EMAIL_1} + - KINDLE_EMAIL_2=${KINDLE_EMAIL_2} + - KINDLE_LABELS=${KINDLE_LABELS:-Madiha,Hafsa} + - TZ=${TZ:-America/Chicago} + networks: + - default + - pangolin + depends_on: + - trips-service + - fitness-service + - inventory-service + - budget-service + + trips-service: + build: + context: ./services/trips + dockerfile: Dockerfile + container_name: platform-trips-service + restart: unless-stopped + volumes: + - ./services/trips/data:/app/data + env_file: + - ./services/trips/.env + environment: + - TZ=${TZ:-America/Chicago} + + fitness-service: + build: + context: ./services/fitness + dockerfile: Dockerfile.backend + container_name: platform-fitness-service + restart: unless-stopped + volumes: + - ./services/fitness/data:/app/data + env_file: + - ./services/fitness/.env + environment: + - PORT=8095 + - DATA_DIR=/app/data + - TZ=${TZ:-America/Chicago} + + inventory-service: + build: + context: ./services/inventory + dockerfile: Dockerfile + container_name: platform-inventory-service + restart: unless-stopped + environment: + - PORT=3000 + - NOCODB_URL=${NOCODB_URL:-http://nocodb:8080} + - NOCODB_PUBLIC_URL=${NOCODB_PUBLIC_URL:-https://noco.quadjourney.com} + - NOCODB_API_TOKEN=${NOCODB_API_TOKEN} + - NOCODB_BASE_ID=${NOCODB_BASE_ID:-pava9q9zccyihpt} + - NOCODB_TABLE_ID=${NOCODB_TABLE_ID:-mash7c5nx4unukc} + - NOCODB_COLUMN_NAME=${NOCODB_COLUMN_NAME:-photos} + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} + - PUBLIC_APP_URL=${PLATFORM_ORIGIN}/inventory + - IMMICH_URL=${IMMICH_URL} + - IMMICH_API_KEY=${IMMICH_API_KEY} + - TZ=${TZ:-America/Chicago} + networks: + - default + - nocodb_default + + budget-service: + build: + context: ./services/budget + dockerfile: Dockerfile + container_name: platform-budget-service + restart: unless-stopped + environment: + - PORT=3001 + - ACTUAL_SERVER_URL=http://actualbudget:5006 + - ACTUAL_PASSWORD=${ACTUAL_PASSWORD} + - ACTUAL_SYNC_ID=${BUDGET_SYNC_ID} + - TZ=${TZ:-America/Chicago} + networks: + - default + - actualbudget_default + +networks: + nocodb_default: + external: true + pangolin: + external: true + actualbudget_default: + external: true diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..20047a3 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,39 @@ +# Platform Architecture + +## Goal + +One frontend product with two sections: + +- `/trips` +- `/fitness` + +Separate backend services remain in place: + +- `services/trips` +- `services/fitness` + +The legacy frontends stay runnable during migration for fallback and comparison. + +## Service Boundaries + +- Trips backend remains independent. +- Fitness backend remains independent. +- Trips and Fitness keep separate SQLite databases. +- Platform frontend owns the shared shell and explicit API proxy layer. + +## Proxy Contract + +- `/api/trips/*` -> Trips backend +- `/api/fitness/*` -> Fitness backend + +Frontend code should call these proxy paths, not raw backend URLs. + +## Migration Order + +1. Keep both legacy frontends alive. +2. Build platform shell. +3. Add explicit proxy/API clients. +4. Migrate one product area first. +5. Migrate the second area. +6. Retire legacy frontends only after full coverage. + diff --git a/frontend-v2/.dockerignore b/frontend-v2/.dockerignore new file mode 100644 index 0000000..18774d6 --- /dev/null +++ b/frontend-v2/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.svelte-kit +build +.env diff --git a/frontend-v2/.gitignore b/frontend-v2/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend-v2/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend-v2/.npmrc b/frontend-v2/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend-v2/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend-v2/.vscode/extensions.json b/frontend-v2/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/frontend-v2/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend-v2/DESIGN_SYSTEM.md b/frontend-v2/DESIGN_SYSTEM.md new file mode 100644 index 0000000..a8108c0 --- /dev/null +++ b/frontend-v2/DESIGN_SYSTEM.md @@ -0,0 +1,324 @@ +# Design System — Token Reference + +> Source of truth: `src/app.css` +> Last updated: 2026-03-27 + +All UI values must come from these tokens unless listed under [Intentional Raw Values](#intentional-raw-values). + +--- + +## Spacing + +4px grid. Token name = multiplier (`--sp-3` = 3 × 4px = 12px). + +| Token | Value | Use for | +|-------|-------|---------| +| `--sp-0` | 0px | Explicit zero | +| `--sp-px` | 1px | Borders, hairlines | +| `--sp-0.5` | 2px | Micro-nudge (margin-top on meta text) | +| `--sp-1` | 4px | Tight gap, field label gap, small padding | +| `--sp-1.5` | 6px | Badge gap, icon gap, footer gap | +| `--sp-2` | 8px | Compact gap, button group gap, inner padding | +| `--sp-3` | 12px | Standard gap, row padding, list item gap | +| `--sp-4` | 16px | Card padding (mobile), section margin, tab margin | +| `--sp-5` | 20px | Card padding (desktop), module gap, sidebar gap | +| `--sp-6` | 24px | Large padding, overlay padding, page container | +| `--sp-7` | 28px | Primary card padding, section group margin | +| `--sp-8` | 32px | Page top padding, empty state, desktop grid gap | +| `--sp-10` | 40px | Large elements (avatar width), empty list padding | +| `--sp-12` | 48px | Empty state padding, large spacing | +| `--sp-16` | 64px | Reserved | +| `--sp-20` | 80px | Page bottom padding (scroll clearance) | + +### Semantic spacing aliases + +| Token | Resolves to | Use for | +|-------|-------------|---------| +| `--section-gap` | `--sp-7` (28px) | Gap between major page sections | +| `--card-pad` | `--sp-5` (20px) | Default module/card padding | +| `--card-pad-primary` | `--sp-7` (28px) | Hero/primary module padding | +| `--card-pad-secondary` | `--sp-4` (16px) | Compact card padding | +| `--row-gap` | `--sp-3` (12px) | Gap between list rows | +| `--module-gap` | `--sp-5` (20px) | Gap between dashboard modules | +| `--row-pad-y` | 14px | Vertical padding inside data rows (off-grid, intentional) | +| `--row-pad-x` | `--sp-4` (16px) | Horizontal padding inside data rows | +| `--inner-gap` | `--sp-3` (12px) | Gap between items within a row | + +### Mobile overrides (≤768px) + +| Token | Desktop | Mobile | +|-------|---------|--------| +| `--card-pad` | 20px | 16px | +| `--card-pad-primary` | 28px | 20px | +| `--row-pad-y` | 14px | 16px | +| `--section-gap` | 28px | 20px | + +--- + +## Radius + +| Token | Value | Use for | +|-------|-------|---------| +| `--radius-xs` | 4px | Tiny pills, skeleton placeholders, kbd hints | +| `--radius-sm` | 6px | Badges, chips, nav links, danger buttons | +| `--radius-md` | 8px | Buttons, inputs, tabs, entry rows, icon containers | +| `--radius` | 12px | Cards, modals, panels, main containers | +| `--radius-lg` | 16px | Hero cards, action cards, pill chips, ImmichPicker modal | +| `--radius-full` | 9999px | Circles, toggles, avatars | + +--- + +## Elevation (Shadows) + +Light and dark mode have separate values. Dark mode uses higher opacity. + +| Token | Light | Use for | +|-------|-------|---------| +| `--shadow-xs` | `0 1px 2px rgba(0,0,0,0.03)` | Row hover, active tabs, inner elements | +| `--shadow-sm` | `0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04)` | Secondary cards, inputs, budget tables | +| `--shadow-md` | `0 2px 6px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06)` | Standard card elevation (default `.module`) | +| `--shadow-lg` | `0 4px 12px rgba(0,0,0,0.06), 0 16px 40px rgba(0,0,0,0.1)` | Hero cards, primary modules, dropdowns | +| `--shadow-xl` | `0 8px 24px rgba(0,0,0,0.08), 0 24px 60px rgba(0,0,0,0.15)` | Modals, overlay panels | + +**Legacy aliases**: `--card-shadow` → `--shadow-md`, `--card-shadow-sm` → `--shadow-sm` + +--- + +## Colors + +### Surfaces (3-layer depth) + +| Token | Light | Dark | Layer | +|-------|-------|------|-------| +| `--canvas` | `#F5F6F8` | `#09090b` | Page background — everything sits on this | +| `--surface` | `#FFFFFF` | `#0f0f12` | Sidebars, panels, slide-out sheets | +| `--surface-secondary` | `#FAFAFB` | `#111114` | Input backgrounds, secondary panels | +| `--card` | `#FFFFFF` | `#161619` | Content containers, elevated with shadow | +| `--card-secondary` | `#FAFAFB` | `#111114` | Secondary cards, button backgrounds | +| `--card-hover` | `#f0f0f3` | `#1c1c20` | Row hover, interactive feedback | + +### Borders + +| Token | Light | Dark | +|-------|-------|------| +| `--border` | `rgba(0,0,0,0.06)` | `rgba(255,255,255,0.06)` | +| `--border-strong` | `rgba(0,0,0,0.1)` | `rgba(255,255,255,0.1)` | + +### Text hierarchy + +| Token | Light | Dark | Use for | +|-------|-------|------|---------| +| `--text-1` | `#1a1a1f` | `#fafafa` | Headings, names, amounts — read first | +| `--text-2` | `#4a4a55` | `#a1a1aa` | Body text, descriptions — read second | +| `--text-3` | `#6b6b76` | `#71717a` | Labels, metadata, captions — supporting | +| `--text-4` | `#b4b4bd` | `#3f3f46` | Placeholders, disabled, timestamps — background | + +### Accent (indigo / blue) + +| Token | Light | Dark | Use for | +|-------|-------|------|---------| +| `--accent` | `#4F46E5` | `#3b82f6` | Primary actions, links, active states | +| `--accent-bg` | `#EEF2FF` | `rgba(59,130,246,0.1)` | Icon wells, strong highlight backgrounds | +| `--accent-dim` | `rgba(79,70,229,0.06)` | `rgba(59,130,246,0.08)` | Subtle hover, selection backgrounds, focus rings | +| `--accent-border` | `rgba(79,70,229,0.10)` | `rgba(59,130,246,0.12)` | Accent-tinted borders | +| `--accent-focus` | `rgba(79,70,229,0.12)` | `rgba(59,130,246,0.15)` | Active states, selection bars | + +### Semantic colors + +| Token | Light | Dark | Use for | +|-------|-------|------|---------| +| `--success` | `#16A34A` | `#22c55e` | Positive values, income, completed | +| `--success-bg` | `#F0FDF4` | `rgba(34,197,94,0.1)` | Icon wells | +| `--success-dim` | `rgba(34,197,94,0.08)` | `rgba(34,197,94,0.08)` | Badge backgrounds | +| `--error` | `#DC2626` | `#ef4444` | Errors, issues, delete actions | +| `--error-bg` | `#FEF2F2` | `rgba(239,68,68,0.1)` | Icon wells | +| `--error-dim` | `rgba(239,68,68,0.08)` | `rgba(239,68,68,0.08)` | Badge backgrounds | +| `--warning` | `#d97706` | `#f59e0b` | Warnings, pending states | +| `--warning-bg` | `rgba(245,158,11,0.08)` | `rgba(245,158,11,0.1)` | Badge backgrounds | + +### Overlay + +| Token | Light | Dark | Use for | +|-------|-------|------|---------| +| `--overlay` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.6)` | Modal backdrop, reading pane overlay | +| `--overlay-strong` | `rgba(0,0,0,0.5)` | `rgba(0,0,0,0.75)` | Heavy overlays | +| `--nav-bg` | `rgba(255,255,255,0.9)` | `rgba(15,15,18,0.9)` | Navbar blur background | + +--- + +## Typography + +| Token | Desktop | Mobile (≤768px) | Use for | +|-------|---------|-----------------|---------| +| `--text-xs` | 11px | 12px | Badges, pills, tiny counters | +| `--text-sm` | 13px | 15px | Labels, meta, captions, button text | +| `--text-base` | 14px | 16px | Body text, list items, inputs | +| `--text-md` | 15px | 17px | Card titles, important rows (16px+ avoids iOS zoom) | +| `--text-lg` | 17px | 18px | Section headers, modal titles | +| `--text-xl` | 22px | 22px | Page titles | +| `--text-2xl` | 28px | 26px | Hero headings | +| `--text-3xl` | 36px | 32px | Large hero numbers | + +### Line heights + +| Token | Value | Use for | +|-------|-------|---------| +| `--leading-tight` | 1.2 | Headings, hero numbers | +| `--leading-snug` | 1.35 | Card titles, compact text | +| `--leading-normal` | 1.5 | Body text | +| `--leading-relaxed` | 1.65 | Article content | +| `--leading-loose` | 1.8 | Long-form reading | + +### Fonts + +| Token | Value | +|-------|-------| +| `--font` | `'Inter', -apple-system, system-ui, sans-serif` | +| `--mono` | `'JetBrains Mono', ui-monospace, monospace` | + +--- + +## Global Component Classes + +Defined in `app.css`, usable in any component without local ` diff --git a/frontend-v2/src/lib/components/dashboard/DashboardActionCard.svelte b/frontend-v2/src/lib/components/dashboard/DashboardActionCard.svelte new file mode 100644 index 0000000..ca00ec8 --- /dev/null +++ b/frontend-v2/src/lib/components/dashboard/DashboardActionCard.svelte @@ -0,0 +1,157 @@ + + + +
+
+ {#if variant === 'budget'} + + {:else if variant === 'inventory'} + + {:else if variant === 'fitness'} + + {/if} +
+
+
{title}
+
{description}
+
+
+
+ {action} + +
+
+ + diff --git a/frontend-v2/src/lib/components/dashboard/FitnessModule.svelte b/frontend-v2/src/lib/components/dashboard/FitnessModule.svelte new file mode 100644 index 0000000..76d9eb8 --- /dev/null +++ b/frontend-v2/src/lib/components/dashboard/FitnessModule.svelte @@ -0,0 +1,199 @@ + + +
+
+
Fitness · Today
+ Details → +
+ + {#if notConnected} +
+
Y
+
+
Yusuf
+
Connect fitness to get started
+
+
+ {:else} +
+
Y
+
+
Yusuf
+
+ {#if loading}...{:else if error}—{:else}{fmt(eaten)} cal · {fmt(remaining)} remaining{/if} +
+
+
+ +
+
+
+ +
+
+
+ {#if loading}...{:else if error}—{:else}{Math.round(proteinCurrent)}/{Math.round(proteinGoal)}g{/if} +
+
protein
+
+
+
+ {#if loading}...{:else if error}—{:else}{Math.round(carbsCurrent)}/{Math.round(carbsGoal)}g{/if} +
+
carbs
+
+
+
+ {#if loading}...{:else if error}—{:else}{Math.round(fatCurrent)}/{Math.round(fatGoal)}g{/if} +
+
fat
+
+
+ {/if} +
+ + diff --git a/frontend-v2/src/lib/components/dashboard/IssuesModule.svelte b/frontend-v2/src/lib/components/dashboard/IssuesModule.svelte new file mode 100644 index 0000000..bab632a --- /dev/null +++ b/frontend-v2/src/lib/components/dashboard/IssuesModule.svelte @@ -0,0 +1,113 @@ + + +
+
+
Issues
+ View all → +
+ +
+ {#if loading} + {#each [1, 2, 3] as _} +
+
+
+
+
+
+ {/each} + {:else if issues.length === 0} +
No issues found
+ {:else} + {#each issues as issue} + +
+
{issue.item}
+
Order #{issue.orderNumber || '—'}
+
+ Issue +
+ {/each} + {/if} +
+
+ + diff --git a/frontend-v2/src/lib/components/layout/CommandPalette.svelte b/frontend-v2/src/lib/components/layout/CommandPalette.svelte new file mode 100644 index 0000000..2696e80 --- /dev/null +++ b/frontend-v2/src/lib/components/layout/CommandPalette.svelte @@ -0,0 +1,187 @@ + + +{#if open} + +
{ if (e.target === e.currentTarget) onclose(); }} onkeydown={handleKeydown}> + +
+{/if} + + diff --git a/frontend-v2/src/lib/components/layout/MobileTabBar.svelte b/frontend-v2/src/lib/components/layout/MobileTabBar.svelte new file mode 100644 index 0000000..47cc154 --- /dev/null +++ b/frontend-v2/src/lib/components/layout/MobileTabBar.svelte @@ -0,0 +1,165 @@ + + +
+ +
+ + +{#if moreOpen} + +
{ if (e.target === e.currentTarget) closeMore(); }} onkeydown={() => {}}> + +
+{/if} + + diff --git a/frontend-v2/src/lib/components/layout/Navbar.svelte b/frontend-v2/src/lib/components/layout/Navbar.svelte new file mode 100644 index 0000000..6577c78 --- /dev/null +++ b/frontend-v2/src/lib/components/layout/Navbar.svelte @@ -0,0 +1,215 @@ + + + + + + + diff --git a/frontend-v2/src/lib/components/media/BookLibrary.svelte b/frontend-v2/src/lib/components/media/BookLibrary.svelte new file mode 100644 index 0000000..ec344b9 --- /dev/null +++ b/frontend-v2/src/lib/components/media/BookLibrary.svelte @@ -0,0 +1,362 @@ + + +
+
+ + +
+ {#if libraries.length > 1} +
+ + {#each libraries as lib} + + {/each} +
+ {/if} +
+ +{#if loading} +
+ {#each Array(8) as _} +
+ {/each} +
+{:else if filtered().length === 0} +
{searchQuery ? `No books found for "${searchQuery}"` : 'No books in library'}
+{:else} +
+ {#each filtered() as book (book.id)} + {@const cover = coverUrl(book)} + +
openBook(book)}> +
+ {#if cover} + { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} /> + + {:else} + {book.title.charAt(0)} + {/if} + {#if book.format} + {book.format} + {/if} +
+
+
{book.title}
+
{book.authors.join(', ') || 'Unknown'}
+
+ {book.libraryName} + {#if book.pageCount}{book.pageCount}p{/if} +
+
+
+ {/each} +
+{/if} + + +{#if selectedBook} + {@const cover = coverUrl(selectedBook)} + + +{/if} + + diff --git a/frontend-v2/src/lib/components/media/BookSearch.svelte b/frontend-v2/src/lib/components/media/BookSearch.svelte new file mode 100644 index 0000000..8b664da --- /dev/null +++ b/frontend-v2/src/lib/components/media/BookSearch.svelte @@ -0,0 +1,434 @@ + + + +
+ + +
+ +{#if kindleConfigured && kindleTargets.length > 0} +
+ + After download, also send to + +
+{/if} + +{#if activeView === 'search'} + + + + {#if searching} +
Searching Anna's Archive, Libgen, Z-Library...
+ {:else if searched && results.length === 0} +
No results found for "{query}"
+ {:else if results.length > 0} + {#if bookMeta}
{results.length} releases found
{/if} +
+ {#each results as release (release.source_id)} + {@const cover = coverUrl(release)} + {@const isDl = downloadingIds.has(release.source_id) || !!downloads[release.source_id]} +
+
+ {#if cover} + (e.currentTarget as HTMLImageElement).style.display='none'} /> + {/if} + {release.format.toUpperCase()} +
+
+
{release.title}
+ {#if release.extra?.author}
{release.extra.author}
{/if} +
+ {#if release.extra?.year}{release.extra.year}{/if} + {#if release.size}{release.size}{/if} + {#if release.language}{release.language.toUpperCase()}{/if} +
+
+
+ {#if libraries.length > 0} + + {/if} + {#if isDl} + {@const dl = downloads[release.source_id]} + {#if dl} + {dl.status} + {#if dl.progress > 0 && dl.progress < 100} +
+ {/if} + {:else} + Queued... + {/if} + {:else} + + {/if} +
+
+ {/each} +
+ {:else} +
+ +
Search for books
+
Anna's Archive, Libgen, Z-Library
+
+ {/if} +{:else} + + {#if downloadCount === 0} +
No downloads yet
+ {:else} +
+ {#each Object.entries(downloads) as [id, dl] (id)} +
+
+
{dl.title || id}
+ {#if dl.author}
{dl.author}
{/if} +
+ {#if dl.format}{dl.format.toUpperCase()}{/if} + {dl.status} + {#if dl.status_message}{dl.status_message}{/if} +
+ {#if dl.status === 'downloading' && dl.progress > 0} +
+ {/if} +
+
+ {#if dl.status === 'complete'} + {#if kindleSent.has(id)} + Sent to Kindle ✓ + {:else if kindleSending.has(id)} + Sending to Kindle... + {/if} + {#if importedIds.has(id)} + Imported ✓ + {:else if importingIds.has(id)} + Importing... + {:else} + {#if libraries.length > 0} + + {/if} + + {/if} + {:else if dl.status === 'error'} + + + {:else if ['queued', 'downloading', 'locating', 'resolving'].includes(dl.status)} + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +{/if} + + diff --git a/frontend-v2/src/lib/components/media/MusicSearch.svelte b/frontend-v2/src/lib/components/media/MusicSearch.svelte new file mode 100644 index 0000000..2ecd7a9 --- /dev/null +++ b/frontend-v2/src/lib/components/media/MusicSearch.svelte @@ -0,0 +1,276 @@ + + + +
+ + +
+ +{#if activeView === 'search'} + + + {#if searching} +
Searching Spotify...
+ {:else if searched && results.length === 0} +
No results for "{query}"
+ {:else if results.length > 0} + {#if searchType === 'track'} + {#if playingEmbed} +
+ +
+ {/if} +
+ {#each results as track (track.id)} + {@const art = albumArt(track)} +
+ + {#if art}{/if} +
+
{track.name}
+
{artistNames(track)}{track.album?.name ? ` · ${track.album.name}` : ''}
+
+ {track.duration_ms ? fmtDuration(track.duration_ms) : ''} + {#if downloading.has(track.id)} + Queued + {:else} + + {/if} +
+ {/each} +
+ {:else} +
+ {#each results as item (item.id)} + {@const art = albumArt(item)} +
+
+ {#if art}{:else}
{/if} +
+
{item.name}
+
+ {#if searchType === 'playlist'}{item.owner?.display_name || ''}{item.tracks?.total ? ` · ${item.tracks.total} tracks` : ''} + {:else if searchType === 'album'}{artistNames(item)}{item.release_date ? ` · ${item.release_date.slice(0, 4)}` : ''} + {:else}{item.genres?.slice(0, 2).join(', ') || 'Artist'}{/if} +
+ +
+ {/each} +
+ {/if} + {:else} +
+ +
Search for music
+
Spotify tracks, albums, playlists
+
+ {/if} +{:else} + + {#if tasks.length === 0} +
No music downloads
+ {:else} +
+ {#each tasks as task (task.task_id)} +
+
+
{task.name || task.task_id}
+ {#if task.artist}
{task.artist}
{/if} +
+ {task.status} + {#if task.completed_items != null && task.total_items}{task.completed_items}/{task.total_items} tracks{/if} + {#if task.speed}{task.speed}{/if} + {#if task.eta}ETA {task.eta}{/if} + {#if task.error_message}{task.error_message}{/if} +
+ {#if task.progress != null && task.progress > 0} +
+ {/if} +
+ {#if ['downloading', 'queued'].includes(task.status)} + + {/if} +
+ {/each} +
+ {/if} +{/if} + + diff --git a/frontend-v2/src/lib/components/shared/ActionCard.svelte b/frontend-v2/src/lib/components/shared/ActionCard.svelte new file mode 100644 index 0000000..1f317c0 --- /dev/null +++ b/frontend-v2/src/lib/components/shared/ActionCard.svelte @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend-v2/src/lib/components/shared/EmptyState.svelte b/frontend-v2/src/lib/components/shared/EmptyState.svelte new file mode 100644 index 0000000..c01a1a1 --- /dev/null +++ b/frontend-v2/src/lib/components/shared/EmptyState.svelte @@ -0,0 +1,61 @@ + + +
+ {#if icon} +
+ {@render icon()} +
+ {/if} +

{title}

+ {#if description} +

{description}

+ {/if} +
+ + diff --git a/frontend-v2/src/lib/components/shared/ImmichPicker.svelte b/frontend-v2/src/lib/components/shared/ImmichPicker.svelte new file mode 100644 index 0000000..082f748 --- /dev/null +++ b/frontend-v2/src/lib/components/shared/ImmichPicker.svelte @@ -0,0 +1,250 @@ + + + +
+ +
e.stopPropagation()}> +
+
Choose from Photos
+ +
+ + + +
+ {#each assets as asset (asset.id)} + +
toggleSelect(asset.id)}> + + {#if selected.has(asset.id)} +
+ +
+ {/if} +
+ {/each} + + {#if loading} + {#each Array(8) as _} +
+ {/each} + {/if} + + {#if !loading && assets.length === 0 && !errorMsg} +
No photos found
+ {/if} + + {#if errorMsg} +
{errorMsg}
+ {/if} +
+ + {#if hasMore && !loading && assets.length > 0} + + {/if} + + +
+
+ + diff --git a/frontend-v2/src/lib/components/shared/ModuleCard.svelte b/frontend-v2/src/lib/components/shared/ModuleCard.svelte new file mode 100644 index 0000000..5d21aef --- /dev/null +++ b/frontend-v2/src/lib/components/shared/ModuleCard.svelte @@ -0,0 +1,69 @@ + + +
+
+

{title}

+ {#if action} + + {/if} +
+
+ {@render children()} +
+
+ + diff --git a/frontend-v2/src/lib/components/shared/PageHeader.svelte b/frontend-v2/src/lib/components/shared/PageHeader.svelte new file mode 100644 index 0000000..1210a04 --- /dev/null +++ b/frontend-v2/src/lib/components/shared/PageHeader.svelte @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend-v2/src/lib/components/shared/PageTabs.svelte b/frontend-v2/src/lib/components/shared/PageTabs.svelte new file mode 100644 index 0000000..74357ff --- /dev/null +++ b/frontend-v2/src/lib/components/shared/PageTabs.svelte @@ -0,0 +1,76 @@ + + +
+ {#each tabs as tab, index} + + {/each} +
+ + diff --git a/frontend-v2/src/lib/components/shared/SearchBar.svelte b/frontend-v2/src/lib/components/shared/SearchBar.svelte new file mode 100644 index 0000000..d7edafd --- /dev/null +++ b/frontend-v2/src/lib/components/shared/SearchBar.svelte @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend-v2/src/lib/components/shared/SkeletonRow.svelte b/frontend-v2/src/lib/components/shared/SkeletonRow.svelte new file mode 100644 index 0000000..5a24dac --- /dev/null +++ b/frontend-v2/src/lib/components/shared/SkeletonRow.svelte @@ -0,0 +1,93 @@ + + +
+ {#each Array(rows) as _, i} +
+
+
+
+
+
+
+
+ {/each} +
+ + diff --git a/frontend-v2/src/lib/components/shared/Toast.svelte b/frontend-v2/src/lib/components/shared/Toast.svelte new file mode 100644 index 0000000..d4efa9f --- /dev/null +++ b/frontend-v2/src/lib/components/shared/Toast.svelte @@ -0,0 +1,95 @@ + + + + +{#if toasts.length > 0} +
+ {#each toasts as item (item.id)} +
+ {#if item.variant === 'success'} + + + + + {:else} + + + + + + {/if} + {item.message} +
+ {/each} +
+{/if} + + diff --git a/frontend-v2/src/lib/components/shared/TransactionRow.svelte b/frontend-v2/src/lib/components/shared/TransactionRow.svelte new file mode 100644 index 0000000..3709353 --- /dev/null +++ b/frontend-v2/src/lib/components/shared/TransactionRow.svelte @@ -0,0 +1,148 @@ + + +
+
{date}
+
+
{payeeName}
+ {#if payeeNote} +
{payeeNote}
+ {/if} +
+ +
+ + {category} + +
+
+ {amount} +
+
+ + diff --git a/frontend-v2/src/lib/components/trips/CreateTripModal.svelte b/frontend-v2/src/lib/components/trips/CreateTripModal.svelte new file mode 100644 index 0000000..a40e179 --- /dev/null +++ b/frontend-v2/src/lib/components/trips/CreateTripModal.svelte @@ -0,0 +1,101 @@ + + +{#if open} + + +{/if} + + diff --git a/frontend-v2/src/lib/components/trips/ImageUpload.svelte b/frontend-v2/src/lib/components/trips/ImageUpload.svelte new file mode 100644 index 0000000..7e216ae --- /dev/null +++ b/frontend-v2/src/lib/components/trips/ImageUpload.svelte @@ -0,0 +1,274 @@ + + +
+ + {#if images.length > 0} +
+ {#each images as img} +
+ + +
+ {/each} +
+ {/if} + + + {#if documents && documents.length > 0} +
+ {#each documents as doc} +
+ + {doc.file_name || doc.original_name || 'Document'} + +
+ {/each} +
+ {/if} + + + {#if entityId} +
+ + + + +
+ {:else} +
Save first to add photos and documents
+ {/if} + + + {#if showSearch} +
+
+ { if (e.key === 'Enter') { e.preventDefault(); searchImages(); } }} /> + + +
+ {#if searchResults.length > 0} +
+ {#each searchResults as result} + + {/each} +
+ {/if} +
+ {/if} +
+ +{#if showImmich} + +{/if} + + diff --git a/frontend-v2/src/lib/components/trips/ItemModal.svelte b/frontend-v2/src/lib/components/trips/ItemModal.svelte new file mode 100644 index 0000000..b19b7f9 --- /dev/null +++ b/frontend-v2/src/lib/components/trips/ItemModal.svelte @@ -0,0 +1,368 @@ + + +{#if open} + + +{/if} + + diff --git a/frontend-v2/src/lib/components/trips/PlacesAutocomplete.svelte b/frontend-v2/src/lib/components/trips/PlacesAutocomplete.svelte new file mode 100644 index 0000000..d3be188 --- /dev/null +++ b/frontend-v2/src/lib/components/trips/PlacesAutocomplete.svelte @@ -0,0 +1,95 @@ + + +
+ { if (predictions.length > 0) showDropdown = true; }} + onblur={() => setTimeout(() => showDropdown = false, 200)} /> + {#if showDropdown} +
+ {#each predictions as pred} + + {/each} +
+ {/if} +
+ + diff --git a/frontend-v2/src/lib/components/trips/TripEditModal.svelte b/frontend-v2/src/lib/components/trips/TripEditModal.svelte new file mode 100644 index 0000000..42f87aa --- /dev/null +++ b/frontend-v2/src/lib/components/trips/TripEditModal.svelte @@ -0,0 +1,193 @@ + + +{#if open} + + +{/if} + + diff --git a/frontend-v2/src/lib/index.ts b/frontend-v2/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend-v2/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend-v2/src/lib/stores/theme.svelte.ts b/frontend-v2/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..2eaa9e8 --- /dev/null +++ b/frontend-v2/src/lib/stores/theme.svelte.ts @@ -0,0 +1,26 @@ +let theme = $state<'light' | 'dark'>('light'); + +export function initTheme() { + if (typeof window === 'undefined') return; + const stored = localStorage.getItem('theme') as 'light' | 'dark' | null; + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + theme = stored ?? (prefersDark ? 'dark' : 'light'); + applyTheme(); +} + +function applyTheme() { + if (typeof document === 'undefined') return; + const root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); +} + +export function toggleTheme() { + theme = theme === 'light' ? 'dark' : 'light'; + localStorage.setItem('theme', theme); + applyTheme(); +} + +export function isDark(): boolean { + return theme === 'dark'; +} diff --git a/frontend-v2/src/lib/utils.ts b/frontend-v2/src/lib/utils.ts new file mode 100644 index 0000000..256f86f --- /dev/null +++ b/frontend-v2/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend-v2/src/routes/(app)/+layout.server.ts b/frontend-v2/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..a8c9200 --- /dev/null +++ b/frontend-v2/src/routes/(app)/+layout.server.ts @@ -0,0 +1,28 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +import { env } from '$env/dynamic/private'; + +const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100'; + +export const load: LayoutServerLoad = async ({ cookies, url }) => { + const session = cookies.get('platform_session'); + if (!session) { + throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`); + } + + // Validate session is still active + try { + const res = await fetch(`${gatewayUrl}/api/auth/me`, { + headers: { Cookie: `platform_session=${session}` } + }); + if (res.ok) { + const data = await res.json(); + if (data.authenticated) { + return { user: data.user }; + } + } + } catch { /* gateway down — let client handle */ } + + // Invalid/expired session + throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`); +}; diff --git a/frontend-v2/src/routes/(app)/+layout.svelte b/frontend-v2/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..99e6e7b --- /dev/null +++ b/frontend-v2/src/routes/(app)/+layout.svelte @@ -0,0 +1,58 @@ + + + + +
+ + +
+ {@render children()} +
+ + + +
+ + diff --git a/frontend-v2/src/routes/(app)/+page.svelte b/frontend-v2/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..ca3dc16 --- /dev/null +++ b/frontend-v2/src/routes/(app)/+page.svelte @@ -0,0 +1,127 @@ + + +
+
+ + +
+ + + +
+ +
+ +
+ + +
+
+
+
+ + diff --git a/frontend-v2/src/routes/(app)/budget/+page.svelte b/frontend-v2/src/routes/(app)/budget/+page.svelte new file mode 100644 index 0000000..9e1c29e --- /dev/null +++ b/frontend-v2/src/routes/(app)/budget/+page.svelte @@ -0,0 +1,763 @@ + + +
+
+ + + + +
+
+
+ {#if activeAccountId} + +
{accounts.find(a => a.id === activeAccountId)?.name || offBudgetAccounts.find(a => a.id === activeAccountId)?.name || 'Account'}
+ {:else} +
Budget
+
{currentMonthLabel} · {headerSpending} spent
+
{headerIncome} income · {uncatCount} uncategorized
+ {/if} +
+ +
+ + +
+ + +
+ + {#if accountsOpen} +
+
Budget{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}
+ {#each accounts as acct} + +
{ selectAccount(acct.id || null); accountsOpen = false; }}>{acct.name}{formatBalance(acct.balance)}
+ {/each} +
Off Budget{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}
+ {#each offBudgetAccounts as acct} + +
{ selectAccount(acct.id || null); accountsOpen = false; }}>{acct.name}{formatBalance(acct.balance)}
+ {/each} +
+ {/if} + + {#if activeView === 'transactions'} + + {#if suggestedTransfers.length > 0} +
+
+ + Suggested Transfers + {suggestedTransfers.length} +
+ {#each suggestedTransfers as s} +
+
+
{s.from.account}
+ +
{s.to.account}
+
+
${s.amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}
+
+ + +
+
+ {/each} +
+ {/if} + + + {#if selected.size > 0} +
+ {selected.size} selected + {#if canTransfer} + + {/if} +
+ {#if bulkCategoryOpen} + + {:else} + + {/if} +
+ +
+ {/if} + + +
+ + + +
+ + +
+ {#each filteredTransactions() as txn (txn.id)} + +
handleRowKeydown(e, txn.id)} + > + toggleSelect(txn.id)} /> +
{txn.date}
+
+ {#if txn.categoryType === 'transfer'} +
+ + {txn.payee} +
+ {:else} +
{txn.payee}
+ {#if txn.note}
{txn.note}
{/if} + {/if} +
+ +
+ {#if txn.categoryType === 'transfer'} + Transfer + {:else if txn.categoryType === 'uncat'} + + {:else} + {txn.category} + {/if} +
+
= 0} class:neg={txn.amount < 0}>{formatAmount(txn.amount)}
+
+ {/each} + {#if hasMore} + + {/if} +
+ {:else} + +
+ {#each budgetGroups as group} +
+
{group.name}
+
+
+ Category + Budgeted + Spent + Available +
+ {#each group.categories as cat} +
+ {cat.name} + {formatBudgetAmount(cat.budgeted)} + {formatBudgetAmount(cat.spent)} + 0} class:negative={cat.available < 0}>{cat.available < 0 ? '-' : ''}{formatBudgetAmount(Math.abs(cat.available))} +
+ {/each} +
+
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/frontend-v2/src/routes/(app)/fitness/+page.svelte b/frontend-v2/src/routes/(app)/fitness/+page.svelte new file mode 100644 index 0000000..9878fd8 --- /dev/null +++ b/frontend-v2/src/routes/(app)/fitness/+page.svelte @@ -0,0 +1,1605 @@ + + +
+
+ + + + +
+ + + +
+ + + + + {#if activeTab === 'log'} + + +
+ + + + {#if !isToday} + + {/if} +
+ + +
+
+ {totals.calories.toLocaleString()} + / {goal.calories.toLocaleString()} +
+
+
+
+
{coachMessage(caloriesRemaining, caloriesPercent)}
+ {#if caloriesRemaining > 0} + {@const hint = bestNextMove(totals, goal, caloriesRemaining)} + {#if hint} + + {/if} + {/if} +
+ + +
+ { if (e.key === 'Enter') submitResolve(); }} + disabled={resolving} + /> + +
+ {#if resolveError} +
{resolveError}
+ {/if} + + +
+
+
+
+
+
+ {totals.protein}g + Protein +
+
+
{macroInstruction('protein', totals.protein, goal.protein, caloriesRemaining)}
+
{macroLeft(totals.protein, goal.protein)}
+
+
+
+
+
+
+
+ {totals.carbs}g + Carbs +
+
+
{macroInstruction('carbs', totals.carbs, goal.carbs, caloriesRemaining)}
+
{macroLeft(totals.carbs, goal.carbs)}
+
+
+
+
+
+
+
+ {totals.fat}g + Fat +
+
+
{macroInstruction('fat', totals.fat, goal.fat, caloriesRemaining)}
+
{macroLeft(totals.fat, goal.fat)}
+
+
+
+ + +
+ Meals + {totals.count} entries +
+ + + {#each mealTypes as meal, i} + {@const mealEntries = entriesByMeal(meal)} + {@const mCal = mealCalories(meal)} + {@const mPro = mealProtein(meal)} + {@const expanded = expandedMeals.has(meal)} + {@const weight = mealWeight(mCal, goal.calories, meal)} + {@const mealPct = mCal > 0 ? Math.round((mCal / goal.calories) * 100) : 0} + +
+ + + {#if expanded} +
+ {#if mealEntries.length > 0} + {#each mealEntries as entry} +
+ +
toggleEntry(entry.id)}> + +
+ {entry.calories} + cal +
+
+ {#if expandedEntry === entry.id} +
+
+ { if (e.key === 'Enter') updateEntryQty(entry.id); }} + step="0.5" + min="0.1" + /> + {entry.rawUnit} + +
+ +
+ {/if} +
+ {/each} + {/if} + + +
+ {/if} +
+ {/each} + + + + + {:else if activeTab === 'foods'} + +
+ Looking for a quick option? +
+ +
+
+ + + {#if foodSearch} + + {/if} +
+
+ +
+ {#each filteredFoods as food (food.name)} + +
openFoodEdit(food)}> +
+
+ {food.name} + {#if food.favorite} + + {/if} +
+
{food.info}
+
+
+ {food.calories} cal + +
+
+ {/each} + {#if filteredFoods.length === 0} +
No foods found for "{foodSearch}"
+ {/if} +
+ + + + + {:else if activeTab === 'templates'} + +
+
{#if templatesLoading}Loading...{:else if templates.length === 0}No quick meals yet{:else}{templates.length} go-to meals · ranked for you{/if}
+
+ +
+ {#each rankedTemplates as tpl} + {@const hint = templateHintMap.get(tpl.name) || ''} +
+
{tpl.meal.charAt(0).toUpperCase()}
+
+
{tpl.name}
+
{tpl.meal} · {tpl.calories} cal
+
{tpl.items} items
+ {#if hint} +
{hint}
+ {/if} +
+
+ + +
+
+ {/each} +
+ + {/if} +
+
+ + + + + +{#if fabOpen} + +
fabOpen = false}>
+
+
+ + + + +
+{/if} + + +{#if editingFood} + +
+ +
e.stopPropagation()}> +
+
Edit Food
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
Per 1 {editFoodUnit}
+
+ +
+
+{/if} + + +{#if resolvedItems.length > 0} + +
+ +
e.stopPropagation()}> +
+
+ {resolvedItems.length === 1 ? 'Confirm entry' : `Confirm ${resolvedItems.length} items`} +
+ +
+ +
+ {#each resolvedItems as item, idx} +
0}> +
+
+
{item.name}
+
{item.calories} cal · {item.protein}g P · {item.carbs}g C · {item.fat}g F
+
+ {#if resolvedItems.length > 1} + + {/if} +
+
+ + {item.qty} {item.unit} + +
+
+ {/each} + +
+ Meal + +
+ + {#if resolvedItems.some(i => i.result.resolution_type === 'ai_estimated')} +
Some items estimated by AI — values are approximate
+ {/if} +
+ + +
+
+{/if} + + diff --git a/frontend-v2/src/routes/(app)/fitness/foods/+page.svelte b/frontend-v2/src/routes/(app)/fitness/foods/+page.svelte new file mode 100644 index 0000000..3e828c9 --- /dev/null +++ b/frontend-v2/src/routes/(app)/fitness/foods/+page.svelte @@ -0,0 +1,149 @@ + + +
+
+ + + + +
+ {#each filteredFoods as food (food.name)} +
+
+
{food.name}
+
{food.info}
+
+
+ {food.calories} + +
+
+ {/each} +
+
+
+ + diff --git a/frontend-v2/src/routes/(app)/fitness/goals/+page.svelte b/frontend-v2/src/routes/(app)/fitness/goals/+page.svelte new file mode 100644 index 0000000..4866e9c --- /dev/null +++ b/frontend-v2/src/routes/(app)/fitness/goals/+page.svelte @@ -0,0 +1,157 @@ + + +
+
+ + +
+
+
CURRENT GOALS
+ +
+ +
+ {#each goals as goal} +
+
{goal.label}
+
{goal.value}
+
{goal.unit}
+
+ {/each} +
+ +
+ Start date + {startDate || '—'} +
+
+
+
+ + diff --git a/frontend-v2/src/routes/(app)/fitness/templates/+page.svelte b/frontend-v2/src/routes/(app)/fitness/templates/+page.svelte new file mode 100644 index 0000000..165d0ec --- /dev/null +++ b/frontend-v2/src/routes/(app)/fitness/templates/+page.svelte @@ -0,0 +1,136 @@ + + +
+
+ + +
+ {#if loading} +
Loading...
+ {:else if templates.length === 0} +
No meal templates yet
+ {:else} + {#each templates as tpl} +
+
{mealIcon(tpl.meal)}
+
+
{tpl.name}
+
{tpl.calories} cal · {tpl.items} items
+
+ +
+ {/each} + {/if} +
+
+
+ + diff --git a/frontend-v2/src/routes/(app)/inventory/+page.svelte b/frontend-v2/src/routes/(app)/inventory/+page.svelte new file mode 100644 index 0000000..625b27f --- /dev/null +++ b/frontend-v2/src/routes/(app)/inventory/+page.svelte @@ -0,0 +1,902 @@ + + +{#snippet editableRow(nocoField: string, displayValue: string, classes: string)} + {#if editingField === nocoField} +
+ {nocoField} + +
+ {:else} + +
startEdit(nocoField, rawField(nocoField))}> + {nocoField} + {displayValue} +
+ {/if} +{/snippet} + +
+
+ + + +
+ + + {#if searchQuery} + + {/if} +
+ + + {#if !searchQuery && searchResults === null} +
+ + + +
+ {:else} +
{displayedItems().length} result{displayedItems().length !== 1 ? 's' : ''} for "{searchQuery}"
+ {/if} + + +
+ {#each displayedItems() as item (item.id)} + + {/each} + {#if displayedItems().length === 0} +
No items found
+ {/if} +
+
+
+ + +{#if detailOpen && selectedItem} + +
+ +
e.stopPropagation()}> + +
+ {#if editingField === 'Item'} + + {:else} + +
startEdit('Item', rawField('Item'))}>{selectedItem.name}
+ {/if} + +
+ + +
+ {#each statusOptions as status} + + {/each} +
+ + +
+ {#if selectedItem.photoUrls.length > 0} + {#each selectedItem.photoUrls as url} + Item photo + {/each} + {:else} +
+ + No photos yet +
+ {/if} +
+ + +
+
+ + + {#if uploadMenuOpen} +
+ + +
+ {/if} + +
+
+ + + + NocoDB + +
+
+ + +
+ +
+ {@render editableRow('Price Per Item', formatPrice(selectedItem.price), 'mono')} + {@render editableRow('Tax', formatPrice(selectedItem.tax), 'mono')} + {@render editableRow('Total', formatPrice(selectedItem.total), 'mono strong')} + {@render editableRow('QTY', String(selectedItem.qty), '')} +
+
+ + +
+ +
+ {@render editableRow('SKU', selectedItem.sku || '—', 'mono')} + {@render editableRow('Serial Numbers', selectedItem.serial || '—', 'mono')} +
+
+ + +
+ +
+ {@render editableRow('Order Number', selectedItem.order || '—', 'mono')} + {@render editableRow('Source', selectedItem.vendor || '—', '')} + {@render editableRow('Name', selectedItem.buyerName || '—', '')} + {@render editableRow('Date', selectedItem.date || '—', '')} +
+
+ + +
+ +
+ {@render editableRow('Tracking Number', selectedItem.tracking || '—', 'mono')} +
+
+ + +
+ +
+ {#if editingField === 'Notes'} +
+ +
+ {:else} + +
startEdit('Notes', rawField('Notes'))}> + {selectedItem.notes || 'Add notes...'} +
+ {/if} +
+
+
+
+{/if} + +{#if immichOpen} + +{/if} + + diff --git a/frontend-v2/src/routes/(app)/inventory/item/+page.svelte b/frontend-v2/src/routes/(app)/inventory/item/+page.svelte new file mode 100644 index 0000000..f6ae483 --- /dev/null +++ b/frontend-v2/src/routes/(app)/inventory/item/+page.svelte @@ -0,0 +1,170 @@ + + +
+
+ + + Back to Inventory + + +
+

Microsoft Surface Laptop Studio - 1TB TBolt

+ Needs Attention +
+ + +
+ {#each [1, 2, 3] as _} +
+ + + + + +
+ {/each} +
+ + +
+ + + + + +
+ + +
+
+
Details
+
+ {#each details as row} +
+ {row.label} + {row.value} +
+ {/each} +
+
+
+ + diff --git a/frontend-v2/src/routes/(app)/media/+page.svelte b/frontend-v2/src/routes/(app)/media/+page.svelte new file mode 100644 index 0000000..f893ff2 --- /dev/null +++ b/frontend-v2/src/routes/(app)/media/+page.svelte @@ -0,0 +1,75 @@ + + +
+
+ + +
+ + + +
+ + {#if activeTab === 'books'} + + {:else if activeTab === 'music'} + + {:else} + + {/if} +
+
+ + diff --git a/frontend-v2/src/routes/(app)/reader/+page.svelte b/frontend-v2/src/routes/(app)/reader/+page.svelte new file mode 100644 index 0000000..1ae89a9 --- /dev/null +++ b/frontend-v2/src/routes/(app)/reader/+page.svelte @@ -0,0 +1,880 @@ + + + + +
+ + + + + + + {#if sidebarOpen} + + + {/if} + + +
+
+
+ +
{activeFeedId ? feedCategories.flatMap(c => c.feeds).find(f => f.id === activeFeedId)?.name || 'Feed' : activeNav} {activeNav === 'Today' && !activeFeedId ? totalUnread : filteredArticles.length}
+
+ + + + {#if autoScrollActive} +
+ + {autoScrollSpeed}x + +
+ {/if} +
+
+
+ + + +
+ {#each filteredArticles as article (article.id)} + +
selectArticle(article)} + > + +
+
+ + {article.feed} + {#if article.author} + · {article.author} + {/if} +
+
+ + {article.timeAgo} +
+
+ + +
{article.title}
+ + + {#if article.thumbnail} +
+ {/if} + + +
{stripHtml(article.content).slice(0, 200)}
+ + + +
+ {/each} + {#if filteredArticles.length === 0} +
No articles to show
+ {/if} +
+
+
+ + +{#if selectedArticle} + +
+ +
e.stopPropagation()}> +
+
+ + + {currentIndex + 1} / {filteredArticles.length} + +
+
+ + + {#if selectedArticle.url} + + + + {/if} +
+
+ +
+
+

{selectedArticle.title}

+
+ {selectedArticle.feed} + + {selectedArticle.timeAgo} + + {selectedArticle.readingTime} read + {#if selectedArticle.author} + + by {selectedArticle.author} + {/if} +
+
+ {@html selectedArticle.content} +
+
+
+
+
+{/if} + + diff --git a/frontend-v2/src/routes/(app)/settings/+page.svelte b/frontend-v2/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..1883ee7 --- /dev/null +++ b/frontend-v2/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,328 @@ + + +
+
+ + + {#if loading} +
Loading...
+ {:else} + +
+
Account
+
+
+
Username
+
{user?.username || '—'}
+
+
+
Display Name
+
{user?.display_name || '—'}
+
+
+
+ +
+
+
+ + +
+
Appearance
+
+
+
+
Theme
+
Switch between light and dark mode
+
+ +
+
+
+ + +
+
Service Connections
+
+ {#each apps as app} +
+
+ {iconMap[app.icon] || '📱'} +
+
{app.name}
+
{app.route_prefix}
+
+
+ {#if app.connected} +
+ Connected + +
+ {:else} + + {/if} +
+ + {#if showConnectForm === app.id && !app.connected} +
+ {#if connectError} +
{connectError}
+ {/if} + + {#if app.id === 'fitness'} + + + {:else} + + {/if} + +
+ + +
+
+ {/if} + {/each} + + {#if apps.length === 0} +
No services configured
+ {/if} +
+
+ {/if} +
+
+ + diff --git a/frontend-v2/src/routes/(app)/trips/+page.svelte b/frontend-v2/src/routes/(app)/trips/+page.svelte new file mode 100644 index 0000000..58ff7db --- /dev/null +++ b/frontend-v2/src/routes/(app)/trips/+page.svelte @@ -0,0 +1,368 @@ + + +
+
+ +
+
+
TRIPS
+
Your Adventures
+
+ +
+ + +
+
+
{stats.trips}
+
Trips
+
+
+
{stats.cities}
+
Cities
+
+
+
{stats.countries}
+
Countries
+
+
+
{formatPoints(stats.points)}
+
Points Used
+
+
+ + +
+ + + {#if searchQuery} + + {/if} +
+ + {#if filteredTrips} + +
{filteredTrips.length} result{filteredTrips.length !== 1 ? 's' : ''}
+ + {:else} + +
+
UPCOMING
+ +
+ + +
+
PAST ADVENTURES
+ +
+ {/if} +
+
+ + goto(`/trips/trip?id=${id}`)} /> + + diff --git a/frontend-v2/src/routes/(app)/trips/trip/+page.svelte b/frontend-v2/src/routes/(app)/trips/trip/+page.svelte new file mode 100644 index 0000000..47950c2 --- /dev/null +++ b/frontend-v2/src/routes/(app)/trips/trip/+page.svelte @@ -0,0 +1,1041 @@ + + +
+
+ + {#if !shareMode} + + + Back to Trips + + {/if} + + +
+
+
+
{trip.name}
+
{trip.dates}{trip.duration ? ' · ' + trip.duration : ''}
+ {#if trip.away}
{trip.away}
{/if} +
+
+ + {currentCoverIdx + 1}/{coverImages.length} + +
+
+ + +
+ + + {#if highlights.length > 0} +
+ {#each highlights as hl} +
+
+ {#if hl.icon === 'star'} + + {:else if hl.icon === 'restaurant'} + + {:else} + + {/if} +
+
+
{hl.label}
+
{hl.value}
+
+
+ {/each} +
+ {/if} + + +
+
+ {tripExpenses.flights} + Flights +
+
+
+ {tripExpenses.hotels} + Hotels +
+
+
+ {tripExpenses.activities} + Activities +
+
+
+ {(tripExpenses.points / 1000).toFixed(0)}K + Points +
+
+
+ ${tripExpenses.cash.toLocaleString()} + Cash +
+
+ + + {#if !shareMode} +
+ + +
+ + + {#if activeView === 'map'} +
+ {#if mapLocations.length > 0} +
+ +
+ {:else} +
+ + No pinned locations yet +
+ {/if} +
+ {:else} +
+
+ + AI Trip Guide +
+
+
Best restaurants near Garden of the Gods
+
Hiking gear checklist for 14ers
+
Weather forecast for Breckenridge Apr 15
+
+
+ + +
+
+ {/if} + {/if} + + +
+
+ + +
+ {#each itinerary as day (day.day)} + {@const isExpanded = expandedDays.has(day.day)} +
+ + + {#if isExpanded} + + {@const story = getDayStory(day.day)} + {#if story} +
+ +
{story.text}
+ {#if story.photos.length > 0} +
+ {#each story.photos as photo} +
+ {/each} +
+ {/if} +
+ {/if} + +
+ {#each day.events as ev} + + {/each} +
+ {/if} +
+ {/each} +
+ +
+ + + + +
+
+
+ + +{#if shareModalOpen} + + +{/if} + + +{#if !shareMode} + + + {#if fabOpen} + +
fabOpen = false}>
+
+
+ + + + + + +
+ {/if} +{/if} + + + + + + + + + diff --git a/frontend-v2/src/routes/(auth)/+layout.svelte b/frontend-v2/src/routes/(auth)/+layout.svelte new file mode 100644 index 0000000..55e85af --- /dev/null +++ b/frontend-v2/src/routes/(auth)/+layout.svelte @@ -0,0 +1,9 @@ + + + + Login — Platform + + +{@render children()} diff --git a/frontend-v2/src/routes/(auth)/login/+page.svelte b/frontend-v2/src/routes/(auth)/login/+page.svelte new file mode 100644 index 0000000..eb51eda --- /dev/null +++ b/frontend-v2/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend-v2/src/routes/+layout.svelte b/frontend-v2/src/routes/+layout.svelte new file mode 100644 index 0000000..050f0e2 --- /dev/null +++ b/frontend-v2/src/routes/+layout.svelte @@ -0,0 +1,20 @@ + + + + + + + Platform + + +{@render children()} diff --git a/frontend-v2/static/robots.txt b/frontend-v2/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend-v2/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend-v2/svelte.config.js b/frontend-v2/svelte.config.js new file mode 100644 index 0000000..ad116dc --- /dev/null +++ b/frontend-v2/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/frontend-v2/tsconfig.json b/frontend-v2/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend-v2/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts new file mode 100644 index 0000000..de03d18 --- /dev/null +++ b/frontend-v2/vite.config.ts @@ -0,0 +1,10 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + allowedHosts: ['test.quadjourney.com'] + } +}); diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 0000000..a134606 --- /dev/null +++ b/gateway/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.12-slim +WORKDIR /app +COPY server.py . +EXPOSE 8100 +CMD ["python3", "server.py"] diff --git a/gateway/server.py b/gateway/server.py new file mode 100644 index 0000000..2fc9ffd --- /dev/null +++ b/gateway/server.py @@ -0,0 +1,1878 @@ +#!/usr/bin/env python3 +""" +Platform Gateway — Auth, session, proxy, dashboard aggregation. +Owns platform identity. Does NOT own business logic. +""" + +import os +import json +import sqlite3 +import hashlib +import secrets +import urllib.request +import urllib.parse +import urllib.error +import ssl +from http.server import HTTPServer, BaseHTTPRequestHandler +from http.cookies import SimpleCookie +from datetime import datetime, timedelta +from pathlib import Path + +# ── Config ── + +PORT = int(os.environ.get("PORT", 8100)) +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DB_PATH = DATA_DIR / "platform.db" + +# Service backends +TRIPS_URL = os.environ.get("TRIPS_BACKEND_URL", "http://localhost:8087") +FITNESS_URL = os.environ.get("FITNESS_BACKEND_URL", "http://localhost:8095") +INVENTORY_URL = os.environ.get("INVENTORY_BACKEND_URL", "http://localhost:4499") +NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "") +MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://localhost:8767") +MINIFLUX_API_KEY = os.environ.get("MINIFLUX_API_KEY", "") +TRIPS_API_TOKEN = os.environ.get("TRIPS_API_TOKEN", "") +SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084") +SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171") +BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001") + +# Booklore (book library manager) +BOOKLORE_URL = os.environ.get("BOOKLORE_URL", "http://booklore:6060") +BOOKLORE_USER = os.environ.get("BOOKLORE_USER", "") +BOOKLORE_PASS = os.environ.get("BOOKLORE_PASS", "") + +# SMTP2GO (email / Send to Kindle) +SMTP2GO_API_KEY = os.environ.get("SMTP2GO_API_KEY", "") +SMTP2GO_FROM_EMAIL = os.environ.get("SMTP2GO_FROM_EMAIL", "") +SMTP2GO_FROM_NAME = os.environ.get("SMTP2GO_FROM_NAME", "Platform") +KINDLE_EMAIL_1 = os.environ.get("KINDLE_EMAIL_1", "") +KINDLE_EMAIL_2 = os.environ.get("KINDLE_EMAIL_2", "") +BOOKLORE_BOOKS_DIR = Path("/booklore-books") +BOOKDROP_DIR = Path("/bookdrop") + +# Karakeep (bookmarking) +KARAKEEP_URL = os.environ.get("KARAKEEP_URL", "http://192.168.1.42:3005") +KARAKEEP_API_KEY = os.environ.get("KARAKEEP_API_KEY", "") +_booklore_token = {"access": "", "refresh": "", "expires": 0} + +# AI +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") +OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2") + +# Session config +SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days + +DATA_DIR.mkdir(parents=True, exist_ok=True) + +# Shared SSL context (skip verification for internal services) +_ssl_ctx = ssl.create_default_context() +_ssl_ctx.check_hostname = False +_ssl_ctx.verify_mode = ssl.CERT_NONE + + +# ── Database ── + +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def init_db(): + conn = get_db() + c = conn.cursor() + + c.execute('''CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS service_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + service TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'bearer', + auth_token TEXT NOT NULL, + metadata TEXT DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, service), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT DEFAULT '', + route_prefix TEXT NOT NULL, + proxy_target TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + dashboard_widget TEXT DEFAULT NULL + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS pinned_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + service TEXT NOT NULL DEFAULT 'inventory', + item_id TEXT NOT NULL, + item_name TEXT NOT NULL DEFAULT '', + pinned_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, service, item_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )''') + + conn.commit() + + # Seed default apps + existing = c.execute("SELECT COUNT(*) FROM apps").fetchone()[0] + if existing == 0: + c.execute("INSERT INTO apps VALUES ('trips', 'Trips', 'map', '/trips', ?, 1, 1, 'upcoming_trips')", (TRIPS_URL,)) + c.execute("INSERT INTO apps VALUES ('fitness', 'Fitness', 'bar-chart', '/fitness', ?, 2, 1, 'daily_calories')", (FITNESS_URL,)) + c.execute("INSERT INTO apps VALUES ('inventory', 'Inventory', 'package', '/inventory', ?, 3, 1, 'items_issues')", (INVENTORY_URL,)) + conn.commit() + else: + # Ensure inventory app exists (migration for existing DBs) + inv = c.execute("SELECT id FROM apps WHERE id = 'inventory'").fetchone() + if not inv: + c.execute("INSERT INTO apps VALUES ('inventory', 'Inventory', 'package', '/inventory', ?, 3, 1, 'items_issues')", (INVENTORY_URL,)) + conn.commit() + print("[Gateway] Added inventory app") + + # Ensure reader app exists + rdr = c.execute("SELECT id FROM apps WHERE id = 'reader'").fetchone() + if not rdr: + c.execute("INSERT INTO apps VALUES ('reader', 'Reader', 'rss', '/reader', ?, 4, 1, 'unread_count')", (MINIFLUX_URL,)) + conn.commit() + print("[Gateway] Added reader app") + + # Ensure books app exists (now media) + books = c.execute("SELECT id FROM apps WHERE id = 'books'").fetchone() + if not books: + c.execute("INSERT INTO apps VALUES ('books', 'Media', 'book', '/media', ?, 5, 1, NULL)", (SHELFMARK_URL,)) + conn.commit() + print("[Gateway] Added media app") + else: + c.execute("UPDATE apps SET name = 'Media', route_prefix = '/media' WHERE id = 'books'") + conn.commit() + + # Ensure music (spotizerr) app exists + music = c.execute("SELECT id FROM apps WHERE id = 'music'").fetchone() + if not music: + c.execute("INSERT INTO apps VALUES ('music', 'Music', 'music', '/music', ?, 6, 1, NULL)", (SPOTIZERR_URL,)) + conn.commit() + print("[Gateway] Added music app") + + # Ensure budget app exists + budget = c.execute("SELECT id FROM apps WHERE id = 'budget'").fetchone() + if not budget: + c.execute("INSERT OR IGNORE INTO apps VALUES ('budget', 'Budget', 'dollar-sign', '/budget', ?, 7, 1, 'budget_summary')", (BUDGET_URL,)) + conn.commit() + print("[Gateway] Added budget app") + + # Seed default admin user if empty + user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if user_count == 0: + pw_hash = hashlib.sha256("admin".encode()).hexdigest() + c.execute("INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)", + ("admin", pw_hash, "Yusuf")) + conn.commit() + print("[Gateway] Created default user: admin / admin") + + conn.close() + + +# ── Session helpers ── + +def create_session(user_id): + token = secrets.token_hex(32) + expires = (datetime.now() + timedelta(seconds=SESSION_MAX_AGE)).isoformat() + conn = get_db() + conn.execute("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + (token, user_id, expires)) + conn.commit() + conn.close() + return token + + +def get_session_user(token): + if not token: + return None + conn = get_db() + row = conn.execute(""" + SELECT u.* FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ? + """, (token, datetime.now().isoformat())).fetchone() + conn.close() + return dict(row) if row else None + + +def delete_session(token): + if not token: + return + conn = get_db() + conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + conn.close() + + +def get_service_token(user_id, service): + conn = get_db() + row = conn.execute( + "SELECT auth_type, auth_token FROM service_connections WHERE user_id = ? AND service = ?", + (user_id, service) + ).fetchone() + conn.close() + return dict(row) if row else None + + +def set_service_token(user_id, service, auth_token, auth_type="bearer"): + conn = get_db() + conn.execute(""" + INSERT INTO service_connections (user_id, service, auth_type, auth_token) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, service) DO UPDATE SET auth_token = ?, auth_type = ? + """, (user_id, service, auth_type, auth_token, auth_token, auth_type)) + conn.commit() + conn.close() + + +def delete_service_token(user_id, service): + conn = get_db() + conn.execute("DELETE FROM service_connections WHERE user_id = ? AND service = ?", + (user_id, service)) + conn.commit() + conn.close() + + +# ── Proxy helper ── + +def proxy_request(target_url, method, headers, body=None, timeout=120): + """Proxy a request to a backend service. Returns (status, response_headers, response_body).""" + try: + req = urllib.request.Request(target_url, data=body, method=method) + for k, v in headers.items(): + req.add_header(k, v) + + with urllib.request.urlopen(req, context=_ssl_ctx, timeout=timeout) as resp: + resp_body = resp.read() + resp_headers = dict(resp.headers) + return resp.status, resp_headers, resp_body + + except urllib.error.HTTPError as e: + body = e.read() if e.fp else b'{}' + return e.code, dict(e.headers), body + except Exception as e: + return 502, {"Content-Type": "application/json"}, json.dumps({"error": f"Service unavailable: {e}"}).encode() + + +# ── Service routing ── + +SERVICE_MAP = {} # populated from DB at startup + + +def load_service_map(): + global SERVICE_MAP + conn = get_db() + rows = conn.execute("SELECT id, proxy_target FROM apps WHERE enabled = 1").fetchall() + conn.close() + SERVICE_MAP = {row["id"]: row["proxy_target"] for row in rows} + + +def resolve_service(path): + """Given /api/trips/foo, return ('trips', 'http://backend:8087', '/api/foo').""" + # Path format: /api/{service_id}/... + parts = path.split("/", 4) # ['', 'api', 'trips', 'foo'] + if len(parts) < 3: + return None, None, None + service_id = parts[2] + target = SERVICE_MAP.get(service_id) + if not target: + return None, None, None + remainder = "/" + "/".join(parts[3:]) if len(parts) > 3 else "/" + # Services that don't use /api prefix (Express apps, etc.) + NO_API_PREFIX_SERVICES = {"inventory", "music", "budget"} + SERVICE_PATH_PREFIX = {"reader": "/v1"} + if service_id in SERVICE_PATH_PREFIX: + backend_path = f"{SERVICE_PATH_PREFIX[service_id]}{remainder}" + elif service_id in NO_API_PREFIX_SERVICES: + backend_path = remainder + elif remainder.startswith("/images/") or remainder.startswith("/documents/"): + backend_path = remainder + else: + backend_path = f"/api{remainder}" + return service_id, target, backend_path + + +# ── Request handler ── + +class GatewayHandler(BaseHTTPRequestHandler): + + def log_message(self, format, *args): + print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}") + + def _send_json(self, data, status=200): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", len(body)) + self.end_headers() + self.wfile.write(body) + + def _get_session_token(self): + cookie = SimpleCookie(self.headers.get("Cookie", "")) + if "platform_session" in cookie: + return cookie["platform_session"].value + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:] + return None + + def _get_user(self): + token = self._get_session_token() + return get_session_user(token) + + def _require_auth(self): + user = self._get_user() + if not user: + self._send_json({"error": "Unauthorized"}, 401) + return None + return user + + def _set_session_cookie(self, token): + self.send_header("Set-Cookie", + f"platform_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}") + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(length) if length > 0 else b"" + + # ── Routing ── + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + self.end_headers() + + def do_GET(self): + path = self.path.split("?")[0] + + # Image serving — try trips first, then fitness + if path.startswith("/images/"): + user = self._get_user() + for service_id, target in SERVICE_MAP.items(): + headers = {} + if user: + svc_token = get_service_token(user["id"], service_id) + if svc_token: + headers["Authorization"] = f"Bearer {svc_token['auth_token']}" + if not headers.get("Authorization") and service_id == "trips" and TRIPS_API_TOKEN: + headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}" + status, resp_headers, resp_body = proxy_request(f"{target}{path}", "GET", headers, timeout=15) + if status == 200: + self.send_response(200) + ct = resp_headers.get("Content-Type", "application/octet-stream") + self.send_header("Content-Type", ct) + self.send_header("Content-Length", len(resp_body)) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(resp_body) + return + self._send_json({"error": "Image not found"}, 404) + return + + # Health check + if path == "/api/health": + self._send_json({"status": "ok", "service": "gateway"}) + return + + # Public: no auth needed + if path == "/api/auth/me": + user = self._get_user() + if user: + self._send_json({ + "authenticated": True, + "user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"]} + }) + else: + self._send_json({"authenticated": False, "user": None}) + return + + # Protected gateway routes + if path == "/api/apps": + user = self._require_auth() + if not user: + return + self._handle_apps(user) + return + + if path == "/api/me": + user = self._require_auth() + if not user: + return + self._handle_me(user) + return + + if path == "/api/me/connections": + user = self._require_auth() + if not user: + return + self._handle_connections(user) + return + + if path == "/api/dashboard": + user = self._require_auth() + if not user: + return + self._handle_dashboard(user) + return + + if path == "/api/pinned": + user = self._require_auth() + if not user: + return + self._handle_get_pinned(user) + return + + # Booklore books list + if path == "/api/booklore/books": + user = self._require_auth() + if not user: + return + self._handle_booklore_books() + return + + # Kindle targets + if path == "/api/kindle/targets": + user = self._require_auth() + if not user: + return + kindle_labels = os.environ.get("KINDLE_LABELS", "Kindle 1,Kindle 2").split(",") + targets = [] + if KINDLE_EMAIL_1: + targets.append({"id": "1", "label": kindle_labels[0].strip() if kindle_labels else "Kindle 1", "email": KINDLE_EMAIL_1}) + if KINDLE_EMAIL_2: + targets.append({"id": "2", "label": kindle_labels[1].strip() if len(kindle_labels) > 1 else "Kindle 2", "email": KINDLE_EMAIL_2}) + self._send_json({"targets": targets, "configured": bool(SMTP2GO_API_KEY and SMTP2GO_FROM_EMAIL)}) + return + + # Booklore book cover proxy + if path.startswith("/api/booklore/books/") and path.endswith("/cover"): + user = self._require_auth() + if not user: + return + book_id = path.split("/")[4] + self._handle_booklore_cover(book_id) + return + + # Downloads status (qBittorrent) + if path == "/api/downloads/status": + user = self._require_auth() + if not user: + return + self._handle_downloads_status() + return + + # Booklore libraries + if path == "/api/booklore/libraries": + user = self._require_auth() + if not user: + return + self._handle_booklore_libraries() + return + + # Image proxy — fetch external images server-side to bypass hotlink blocks + if path == "/api/image-proxy": + user = self._require_auth() + if not user: + return + self._handle_image_proxy() + return + + # Service proxy + if path.startswith("/api/"): + self._proxy("GET", path) + return + + self._send_json({"error": "Not found"}, 404) + + def do_POST(self): + path = self.path.split("?")[0] + body = self._read_body() + + # Auth routes (public) + if path == "/api/auth/login": + self._handle_login(body) + return + + if path == "/api/auth/logout": + self._handle_logout() + return + + if path == "/api/auth/register": + self._handle_register(body) + return + + # Connection management + if path == "/api/me/connections": + user = self._require_auth() + if not user: + return + self._handle_set_connection(user, body) + return + + # Pin/unpin items + if path == "/api/pin": + user = self._require_auth() + if not user: + return + self._handle_pin(user, body) + return + + if path == "/api/unpin": + user = self._require_auth() + if not user: + return + self._handle_unpin(user, body) + return + + # Send downloaded file to Kindle (by filename from bookdrop) + if path == "/api/kindle/send-file": + user = self._require_auth() + if not user: + return + self._handle_send_file_to_kindle(body) + return + + # Send book to Kindle + if path.startswith("/api/booklore/books/") and path.endswith("/send-to-kindle"): + user = self._require_auth() + if not user: + return + book_id = path.split("/")[4] + self._handle_send_to_kindle(book_id, body) + return + + # Booklore auto-import from bookdrop + if path == "/api/booklore/import": + user = self._require_auth() + if not user: + return + self._handle_booklore_import(body) + return + + if path == "/api/karakeep/save": + user = self._require_auth() + if not user: + return + self._handle_karakeep_save(body) + return + + if path == "/api/karakeep/delete": + user = self._require_auth() + if not user: + return + self._handle_karakeep_delete(body) + return + + # Command bar — execute natural language actions + if path == "/api/command": + user = self._require_auth() + if not user: + return + self._handle_command(user, body) + return + + # Service proxy + if path.startswith("/api/"): + self._proxy("POST", path, body) + return + + self._send_json({"error": "Not found"}, 404) + + def do_PUT(self): + path = self.path.split("?")[0] + body = self._read_body() + if path.startswith("/api/"): + self._proxy("PUT", path, body) + return + self._send_json({"error": "Not found"}, 404) + + def do_PATCH(self): + path = self.path.split("?")[0] + body = self._read_body() + if path.startswith("/api/"): + self._proxy("PATCH", path, body) + return + self._send_json({"error": "Not found"}, 404) + + def do_DELETE(self): + path = self.path.split("?")[0] + body = self._read_body() + if path.startswith("/api/"): + self._proxy("DELETE", path, body) + return + self._send_json({"error": "Not found"}, 404) + + # ── Auth handlers ── + + def _handle_login(self, body): + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + username = data.get("username", "").strip().lower() + password = data.get("password", "") + + if not username or not password: + self._send_json({"error": "Username and password required"}, 400) + return + + pw_hash = hashlib.sha256(password.encode()).hexdigest() + + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE username = ? AND password_hash = ?", + (username, pw_hash)).fetchone() + conn.close() + + if not user: + self._send_json({"error": "Invalid credentials"}, 401) + return + + token = create_session(user["id"]) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self._set_session_cookie(token) + resp = json.dumps({ + "success": True, + "user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"]} + }).encode() + self.send_header("Content-Length", len(resp)) + self.end_headers() + self.wfile.write(resp) + + def _handle_logout(self): + token = self._get_session_token() + delete_session(token) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", "platform_session=; Path=/; Max-Age=0") + resp = b'{"success": true}' + self.send_header("Content-Length", len(resp)) + self.end_headers() + self.wfile.write(resp) + + def _handle_register(self, body): + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + username = data.get("username", "").strip().lower() + password = data.get("password", "") + display_name = data.get("display_name", username) + + if not username or not password: + self._send_json({"error": "Username and password required"}, 400) + return + + pw_hash = hashlib.sha256(password.encode()).hexdigest() + + conn = get_db() + try: + conn.execute("INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)", + (username, pw_hash, display_name)) + conn.commit() + user_id = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()["id"] + conn.close() + self._send_json({"success": True, "user_id": user_id}) + except sqlite3.IntegrityError: + conn.close() + self._send_json({"error": "Username already exists"}, 409) + + # ── Platform data handlers ── + + def _handle_apps(self, user): + conn = get_db() + rows = conn.execute("SELECT * FROM apps WHERE enabled = 1 ORDER BY sort_order").fetchall() + + # Check which services the user has connected + connections = conn.execute("SELECT service FROM service_connections WHERE user_id = ?", + (user["id"],)).fetchall() + connected = {r["service"] for r in connections} + conn.close() + + apps = [] + for row in rows: + app = dict(row) + app["connected"] = app["id"] in connected + del app["proxy_target"] # don't expose internal URLs + apps.append(app) + + self._send_json({"apps": apps}) + + def _handle_me(self, user): + conn = get_db() + connections = conn.execute( + "SELECT service, auth_type, created_at FROM service_connections WHERE user_id = ?", + (user["id"],) + ).fetchall() + conn.close() + + self._send_json({ + "user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "created_at": user["created_at"] + }, + "connections": [dict(c) for c in connections] + }) + + def _handle_connections(self, user): + conn = get_db() + connections = conn.execute( + "SELECT service, auth_type, created_at FROM service_connections WHERE user_id = ?", + (user["id"],) + ).fetchall() + conn.close() + self._send_json({"connections": [dict(c) for c in connections]}) + + def _handle_set_connection(self, user, body): + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + service = data.get("service", "") + action = data.get("action", "connect") # connect or disconnect + + if action == "disconnect": + delete_service_token(user["id"], service) + self._send_json({"success": True}) + return + + auth_token = data.get("token", "") + auth_type = data.get("auth_type", "bearer") + + if not service or not auth_token: + self._send_json({"error": "service and token required"}, 400) + return + + # Validate token against the service + target = SERVICE_MAP.get(service) + if not target: + self._send_json({"error": f"Unknown service: {service}"}, 400) + return + + # Test the token + if service == "trips": + test_url = f"{target}/api/trips" + headers = {"Authorization": f"Bearer {auth_token}"} + elif service == "fitness": + test_url = f"{target}/api/users" + headers = {"Authorization": f"Bearer {auth_token}"} + else: + test_url = f"{target}/api/health" + headers = {"Authorization": f"Bearer {auth_token}"} + + status, _, _ = proxy_request(test_url, "GET", headers, timeout=10) + if status == 401: + self._send_json({"error": "Invalid token for this service"}, 401) + return + + set_service_token(user["id"], service, auth_token, auth_type) + self._send_json({"success": True}) + + def _handle_pin(self, user, body): + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + item_id = str(data.get("item_id", "")) + item_name = data.get("item_name", "") + service = data.get("service", "inventory") + if not item_id: + self._send_json({"error": "item_id required"}, 400) + return + conn = get_db() + conn.execute(""" + INSERT INTO pinned_items (user_id, service, item_id, item_name) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, service, item_id) DO UPDATE SET item_name = ?, pinned_at = CURRENT_TIMESTAMP + """, (user["id"], service, item_id, item_name, item_name)) + conn.commit() + conn.close() + self._send_json({"success": True}) + + def _handle_unpin(self, user, body): + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + item_id = str(data.get("item_id", "")) + service = data.get("service", "inventory") + conn = get_db() + conn.execute("DELETE FROM pinned_items WHERE user_id = ? AND service = ? AND item_id = ?", + (user["id"], service, item_id)) + conn.commit() + conn.close() + self._send_json({"success": True}) + + # ── Booklore integration ── + + def _booklore_auth(self): + """Get a valid Booklore JWT token, refreshing if needed.""" + import time + global _booklore_token + if _booklore_token["access"] and time.time() < _booklore_token["expires"] - 60: + return _booklore_token["access"] + if not BOOKLORE_USER or not BOOKLORE_PASS: + return None + try: + body = json.dumps({"username": BOOKLORE_USER, "password": BOOKLORE_PASS}).encode() + status, _, resp = proxy_request( + f"{BOOKLORE_URL}/api/v1/auth/login", "POST", + {"Content-Type": "application/json"}, body, timeout=10 + ) + if status == 200: + data = json.loads(resp) + _booklore_token["access"] = data["accessToken"] + _booklore_token["refresh"] = data.get("refreshToken", "") + _booklore_token["expires"] = time.time() + 3600 # 1hr + return _booklore_token["access"] + except Exception as e: + print(f"[Booklore] Auth failed: {e}") + return None + + def _handle_booklore_libraries(self): + """Return Booklore libraries with their paths.""" + token = self._booklore_auth() + if not token: + self._send_json({"error": "Booklore auth failed"}, 502) + return + status, _, resp = proxy_request( + f"{BOOKLORE_URL}/api/v1/libraries", "GET", + {"Authorization": f"Bearer {token}"}, timeout=10 + ) + if status == 200: + libs = json.loads(resp) + result = [] + for lib in libs: + paths = [{"id": p["id"], "path": p.get("path", "")} for p in lib.get("paths", [])] + result.append({"id": lib["id"], "name": lib["name"], "paths": paths}) + self._send_json({"libraries": result}) + else: + self._send_json({"error": "Failed to fetch libraries"}, status) + + def _handle_booklore_import(self, body): + """Auto-import a file from bookdrop into a Booklore library. + + Expects: {"fileName": "...", "libraryId": N, "pathId": N} + Flow: rescan bookdrop → find file → finalize import + """ + import time + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + file_name = data.get("fileName", "") + library_id = data.get("libraryId") + path_id = data.get("pathId") + if not file_name or not library_id or not path_id: + self._send_json({"error": "Missing fileName, libraryId, or pathId"}, 400) + return + + token = self._booklore_auth() + if not token: + self._send_json({"error": "Booklore auth failed"}, 502) + return + + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + # 1. Trigger bookdrop rescan + proxy_request(f"{BOOKLORE_URL}/api/v1/bookdrop/rescan", "POST", headers, b"{}", timeout=15) + + # 2. Poll for the file to appear (up to 15 seconds) + file_id = None + file_meta = None + for _ in range(6): + time.sleep(2.5) + s, _, r = proxy_request( + f"{BOOKLORE_URL}/api/v1/bookdrop/files?status=pending&page=0&size=100", + "GET", {"Authorization": f"Bearer {token}"}, timeout=10 + ) + if s == 200: + files_data = json.loads(r) + for f in files_data.get("content", []): + if f.get("fileName", "") == file_name: + file_id = f["id"] + file_meta = f.get("originalMetadata") or f.get("fetchedMetadata") + break + if file_id: + break + + if not file_id: + self._send_json({"error": "File not found in bookdrop after rescan", "fileName": file_name}, 404) + return + + # 3. Build metadata with thumbnailUrl (required by Booklore) + metadata = { + "title": (file_meta or {}).get("title", file_name), + "subtitle": "", + "authors": (file_meta or {}).get("authors", []), + "categories": (file_meta or {}).get("categories", []), + "moods": [], + "tags": [], + "publisher": (file_meta or {}).get("publisher", ""), + "publishedDate": (file_meta or {}).get("publishedDate", ""), + "description": (file_meta or {}).get("description", ""), + "isbn": (file_meta or {}).get("isbn13", (file_meta or {}).get("isbn10", "")), + "language": (file_meta or {}).get("language", ""), + "seriesName": (file_meta or {}).get("seriesName", ""), + "seriesNumber": (file_meta or {}).get("seriesNumber"), + "seriesTotal": (file_meta or {}).get("seriesTotal"), + "thumbnailUrl": (file_meta or {}).get("thumbnailUrl", ""), + } + + # 4. Finalize import + payload = json.dumps({"files": [{"fileId": file_id, "libraryId": library_id, "pathId": path_id, "metadata": metadata}]}).encode() + s, _, r = proxy_request( + f"{BOOKLORE_URL}/api/v1/bookdrop/imports/finalize", "POST", + headers, payload, timeout=30 + ) + if s == 200: + result = json.loads(r) + self._send_json(result) + else: + print(f"[Booklore] Finalize failed ({s}): {r[:200]}") + self._send_json({"error": "Finalize import failed", "status": s}, s) + + def _handle_booklore_books(self): + """Return all books from Booklore.""" + token = self._booklore_auth() + if not token: + self._send_json({"error": "Booklore auth failed"}, 502) + return + try: + s, _, r = proxy_request( + f"{BOOKLORE_URL}/api/v1/books", "GET", + {"Authorization": f"Bearer {token}"}, timeout=15 + ) + if s == 200: + books_raw = json.loads(r) + books = [] + for b in books_raw: + m = b.get("metadata") or {} + books.append({ + "id": b["id"], + "title": m.get("title") or "Untitled", + "authors": m.get("authors") or [], + "libraryId": b.get("libraryId"), + "libraryName": b.get("libraryName"), + "categories": m.get("categories") or [], + "pageCount": m.get("pageCount"), + "publisher": m.get("publisher"), + "isbn13": m.get("isbn13"), + "isbn10": m.get("isbn10"), + "googleId": m.get("googleId"), + "addedOn": b.get("addedOn"), + }) + # Resolve file formats from disk + if BOOKLORE_BOOKS_DIR.exists(): + # Build index: lowercase title words → file path + file_index = {} + for ext in ["epub", "pdf", "mobi", "azw3"]: + for fp in BOOKLORE_BOOKS_DIR.rglob(f"*.{ext}"): + file_index[fp.stem.lower()] = fp + for book in books: + title_words = set(book["title"].lower().split()[:4]) + best_match = None + best_score = 0 + for fname, fp in file_index.items(): + matches = sum(1 for w in title_words if w in fname) + score = matches / len(title_words) if title_words else 0 + if score > best_score: + best_score = score + best_match = fp + if best_match and best_score >= 0.5: + book["format"] = best_match.suffix.lstrip(".").upper() + else: + book["format"] = None + + self._send_json({"books": books, "total": len(books)}) + else: + self._send_json({"error": "Failed to fetch books"}, s) + except Exception as e: + self._send_json({"error": str(e)}, 500) + + def _handle_booklore_cover(self, book_id): + """Proxy book cover image from Booklore.""" + token = self._booklore_auth() + if not token: + self._send_json({"error": "Booklore auth failed"}, 502) + return + try: + s, headers_raw, body = proxy_request( + f"{BOOKLORE_URL}/api/v1/books/{book_id}/cover", "GET", + {"Authorization": f"Bearer {token}"}, timeout=10 + ) + if s == 200 and isinstance(body, bytes): + ct = "image/jpeg" + if isinstance(headers_raw, dict): + ct = headers_raw.get("Content-Type", ct) + self.send_response(200) + self.send_header("Content-Type", ct) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + if isinstance(body, str): + self.wfile.write(body.encode()) + else: + self.wfile.write(body) + return + self._send_json({"error": "Cover not found"}, 404) + except Exception as e: + self._send_json({"error": str(e)}, 500) + + def _handle_downloads_status(self): + """Get active downloads from qBittorrent.""" + import urllib.parse + qbt_host = os.environ.get("QBITTORRENT_HOST", "192.168.1.42") + qbt_port = os.environ.get("QBITTORRENT_PORT", "8080") + qbt_user = os.environ.get("QBITTORRENT_USERNAME", "admin") + qbt_pass = os.environ.get("QBITTORRENT_PASSWORD", "") + base = f"http://{qbt_host}:{qbt_port}" + try: + # Login + login_data = urllib.parse.urlencode({"username": qbt_user, "password": qbt_pass}).encode() + req = urllib.request.Request(f"{base}/api/v2/auth/login", data=login_data) + with urllib.request.urlopen(req, timeout=5) as resp: + cookie = resp.headers.get("Set-Cookie", "").split(";")[0] + + # Get torrents + req2 = urllib.request.Request(f"{base}/api/v2/torrents/info?filter=all&sort=added_on&reverse=true&limit=20", + headers={"Cookie": cookie}) + with urllib.request.urlopen(req2, timeout=5) as resp2: + torrents_raw = json.loads(resp2.read()) + + torrents = [] + for t in torrents_raw: + torrents.append({ + "hash": t["hash"], + "name": t["name"], + "progress": round(t["progress"] * 100, 1), + "state": t["state"], + "size": t["total_size"], + "downloaded": t["downloaded"], + "dlSpeed": t["dlspeed"], + "upSpeed": t["upspeed"], + "eta": t.get("eta", 0), + "addedOn": t.get("added_on", 0), + "category": t.get("category", ""), + }) + self._send_json({"torrents": torrents, "total": len(torrents)}) + except Exception as e: + self._send_json({"torrents": [], "total": 0, "error": str(e)}) + + def _find_book_file(self, book_id: str) -> tuple[Path | None, dict | None]: + """Find the actual ebook file for a Booklore book ID. + Returns (file_path, book_metadata) or (None, None).""" + token = self._booklore_auth() + if not token: + return None, None + + # Get book metadata from Booklore + s, _, r = proxy_request( + f"{BOOKLORE_URL}/api/v1/books/{book_id}", "GET", + {"Authorization": f"Bearer {token}"}, timeout=10 + ) + if s != 200: + return None, None + + book = json.loads(r) + meta = book.get("metadata", {}) + title = meta.get("title", "") + lib_name = book.get("libraryName", "") + + if not title or not BOOKLORE_BOOKS_DIR.exists(): + return None, meta + + # Search for the file in the library directory + # Booklore libraries map to subdirectories: "Islamic Books" → /booklore-books/Adult Books/ + # We search all subdirectories for a file matching the title + import glob + title_lower = title.lower() + title_words = set(title_lower.split()[:4]) # First 4 words for matching + + best_match = None + best_score = 0 + + for ext in ["epub", "pdf", "mobi", "azw3"]: + for filepath in BOOKLORE_BOOKS_DIR.rglob(f"*.{ext}"): + fname = filepath.stem.lower() + # Check if title words appear in filename + matches = sum(1 for w in title_words if w in fname) + score = matches / len(title_words) if title_words else 0 + # Prefer epub > pdf > mobi > azw3 + ext_bonus = {"epub": 0.1, "pdf": 0.05, "mobi": 0.03, "azw3": 0.02}.get(ext, 0) + score += ext_bonus + if score > best_score: + best_score = score + best_match = filepath + + if best_match and best_score >= 0.5: + return best_match, meta + return None, meta + + def _handle_send_to_kindle(self, book_id: str, body: bytes): + """Send a book file to a Kindle email via SMTP2GO API.""" + if not SMTP2GO_API_KEY or not SMTP2GO_FROM_EMAIL: + self._send_json({"error": "Email not configured"}, 502) + return + + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + target = data.get("target", "1") + kindle_email = KINDLE_EMAIL_1 if target == "1" else KINDLE_EMAIL_2 + if not kindle_email: + self._send_json({"error": f"Kindle target {target} not configured"}, 400) + return + + # Find the book file + file_path, meta = self._find_book_file(book_id) + if not file_path or not file_path.exists(): + self._send_json({"error": "Book file not found on disk"}, 404) + return + + title = meta.get("title", "Book") if meta else "Book" + author = ", ".join(meta.get("authors", [])) if meta else "" + + # Read file and encode as base64 + import base64 + file_data = file_path.read_bytes() + file_b64 = base64.b64encode(file_data).decode("ascii") + filename = file_path.name + + # Determine MIME type + ext = file_path.suffix.lower() + mime_map = {".epub": "application/epub+zip", ".pdf": "application/pdf", ".mobi": "application/x-mobipocket-ebook", ".azw3": "application/x-mobi8-ebook"} + mime_type = mime_map.get(ext, "application/octet-stream") + + # Send via SMTP2GO API + email_payload = { + "api_key": SMTP2GO_API_KEY, + "sender": f"{SMTP2GO_FROM_NAME} <{SMTP2GO_FROM_EMAIL}>", + "to": [kindle_email], + "subject": f"{title}" + (f" - {author}" if author else ""), + "text_body": f"Sent from Platform: {title}" + (f" by {author}" if author else ""), + "attachments": [{ + "filename": filename, + "fileblob": file_b64, + "mimetype": mime_type, + }] + } + + try: + req_body = json.dumps(email_payload).encode("utf-8") + req = urllib.request.Request( + "https://api.smtp2go.com/v3/email/send", + data=req_body, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + result = json.loads(resp.read()) + + if result.get("data", {}).get("succeeded", 0) > 0: + self._send_json({ + "success": True, + "title": title, + "sentTo": kindle_email, + "format": ext.lstrip(".").upper(), + "size": len(file_data), + }) + else: + self._send_json({"error": "Email send failed", "detail": result}, 500) + except Exception as e: + self._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500) + + def _handle_send_file_to_kindle(self, body: bytes): + """Send a downloaded file to Kindle by filename from bookdrop directory.""" + if not SMTP2GO_API_KEY or not SMTP2GO_FROM_EMAIL: + self._send_json({"error": "Email not configured"}, 502) + return + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + filename = data.get("filename", "") + target = data.get("target", "1") + title = data.get("title", filename) + + kindle_email = KINDLE_EMAIL_1 if target == "1" else KINDLE_EMAIL_2 + if not kindle_email: + self._send_json({"error": f"Kindle target {target} not configured"}, 400) + return + + # Find file in bookdrop or booklore-books + file_path = None + for search_dir in [BOOKDROP_DIR, BOOKLORE_BOOKS_DIR]: + if not search_dir.exists(): + continue + for fp in search_dir.rglob("*"): + if fp.is_file() and fp.name == filename: + file_path = fp + break + if file_path: + break + + if not file_path or not file_path.exists(): + self._send_json({"error": f"File not found: {filename}"}, 404) + return + + import base64 + file_data = file_path.read_bytes() + file_b64 = base64.b64encode(file_data).decode("ascii") + + ext = file_path.suffix.lower() + mime_map = {".epub": "application/epub+zip", ".pdf": "application/pdf", ".mobi": "application/x-mobipocket-ebook", ".azw3": "application/x-mobi8-ebook"} + mime_type = mime_map.get(ext, "application/octet-stream") + + email_payload = { + "api_key": SMTP2GO_API_KEY, + "sender": f"{SMTP2GO_FROM_NAME} <{SMTP2GO_FROM_EMAIL}>", + "to": [kindle_email], + "subject": title, + "text_body": f"Sent from Platform: {title}", + "attachments": [{"filename": filename, "fileblob": file_b64, "mimetype": mime_type}] + } + try: + req_body = json.dumps(email_payload).encode("utf-8") + req = urllib.request.Request("https://api.smtp2go.com/v3/email/send", data=req_body, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + result = json.loads(resp.read()) + if result.get("data", {}).get("succeeded", 0) > 0: + self._send_json({"success": True, "title": title, "sentTo": kindle_email, "format": ext.lstrip(".").upper()}) + else: + self._send_json({"error": "Email send failed", "detail": result}, 500) + except Exception as e: + self._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500) + + def _handle_karakeep_save(self, body): + """Save a URL to Karakeep as a bookmark.""" + if not KARAKEEP_API_KEY: + self._send_json({"error": "Karakeep not configured"}, 502) + return + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + url = data.get("url", "") + if not url: + self._send_json({"error": "Missing url"}, 400) + return + + payload = json.dumps({"type": "link", "url": url}).encode() + headers = { + "Authorization": f"Bearer {KARAKEEP_API_KEY}", + "Content-Type": "application/json", + } + try: + status, _, resp = proxy_request( + f"{KARAKEEP_URL}/api/v1/bookmarks", "POST", + headers, payload, timeout=15 + ) + if status in (200, 201): + result = json.loads(resp) + self._send_json({"ok": True, "id": result.get("id", "")}) + else: + print(f"[Karakeep] Save failed ({status}): {resp[:200]}") + self._send_json({"error": "Failed to save", "status": status}, status) + except Exception as e: + print(f"[Karakeep] Error: {e}") + self._send_json({"error": str(e)}, 500) + + def _handle_karakeep_delete(self, body): + """Delete a bookmark from Karakeep.""" + if not KARAKEEP_API_KEY: + self._send_json({"error": "Karakeep not configured"}, 502) + return + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + bookmark_id = data.get("id", "") + if not bookmark_id: + self._send_json({"error": "Missing id"}, 400) + return + headers = { + "Authorization": f"Bearer {KARAKEEP_API_KEY}", + } + try: + status, _, resp = proxy_request( + f"{KARAKEEP_URL}/api/v1/bookmarks/{bookmark_id}", "DELETE", + headers, timeout=10 + ) + if status in (200, 204): + self._send_json({"ok": True}) + else: + self._send_json({"error": "Delete failed", "status": status}, status) + except Exception as e: + print(f"[Karakeep] Delete error: {e}") + self._send_json({"error": str(e)}, 500) + + def _handle_image_proxy(self): + """Proxy external images to bypass hotlink protection (e.g. Reddit).""" + qs = urllib.parse.urlparse(self.path).query + params = urllib.parse.parse_qs(qs) + url = params.get("url", [None])[0] + if not url: + self._send_json({"error": "Missing url parameter"}, 400) + return + try: + req = urllib.request.Request(url, headers={ + "User-Agent": "Mozilla/5.0 (compatible; PlatformProxy/1.0)", + "Accept": "image/*,*/*", + "Referer": urllib.parse.urlparse(url).scheme + "://" + urllib.parse.urlparse(url).netloc + "/", + }) + resp = urllib.request.urlopen(req, timeout=10, context=_ssl_ctx) + body = resp.read() + ct = resp.headers.get("Content-Type", "image/jpeg") + self.send_response(200) + self.send_header("Content-Type", ct) + self.send_header("Content-Length", len(body)) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(body) + except Exception as e: + print(f"[ImageProxy] Error fetching {url}: {e}") + self._send_json({"error": "Failed to fetch image"}, 502) + + def _handle_get_pinned(self, user): + conn = get_db() + rows = conn.execute( + "SELECT * FROM pinned_items WHERE user_id = ? ORDER BY pinned_at DESC", + (user["id"],) + ).fetchall() + conn.close() + self._send_json({"pinned": [dict(r) for r in rows]}) + + def _handle_dashboard(self, user): + """Aggregate dashboard data from connected services — all fetches in parallel.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + conn = get_db() + apps = conn.execute("SELECT * FROM apps WHERE enabled = 1 ORDER BY sort_order").fetchall() + conn.close() + + SERVICE_LEVEL_AUTH = {"inventory", "reader", "books", "music", "budget"} + widgets = [] + futures = {} + + def fetch_trips(app, headers): + target = app["proxy_target"] + widget_data = None + s1, _, b1 = proxy_request(f"{target}/api/trips", "GET", headers, timeout=10) + s2, _, b2 = proxy_request(f"{target}/api/stats", "GET", headers, timeout=10) + if s1 == 200: + trips = json.loads(b1).get("trips", []) + now = datetime.now().strftime("%Y-%m-%d") + active = [t for t in trips if t.get("start_date", "") <= now <= t.get("end_date", "")] + upcoming = sorted( + [t for t in trips if t.get("start_date", "") > now], + key=lambda t: t.get("start_date", "") + )[:3] + widget_data = {"active": active[:1], "upcoming": upcoming} + if s2 == 200: + widget_data["stats"] = json.loads(b2) + return widget_data + + def fetch_fitness(app, headers): + target = app["proxy_target"] + today = datetime.now().strftime("%Y-%m-%d") + s1, _, b1 = proxy_request(f"{target}/api/users", "GET", headers, timeout=10) + if s1 != 200: + return None + users = json.loads(b1) + + # Fetch all user data in parallel + def get_user_data(u): + s2, _, b2 = proxy_request(f"{target}/api/entries/totals?date={today}&user_id={u['id']}", "GET", headers, timeout=5) + s3, _, b3 = proxy_request(f"{target}/api/goals/for-date?date={today}&user_id={u['id']}", "GET", headers, timeout=5) + return { + "user": u, + "totals": json.loads(b2) if s2 == 200 else None, + "goal": json.loads(b3) if s3 == 200 else None + } + + with ThreadPoolExecutor(max_workers=4) as inner: + people = list(inner.map(get_user_data, users[:4])) + return {"people": people, "date": today} + + def fetch_inventory(app): + target = app["proxy_target"] + s1, _, b1 = proxy_request(f"{target}/needs-review-count", "GET", {}, timeout=10) + return json.loads(b1) if s1 == 200 else None + + def fetch_budget(app): + try: + s, _, b = proxy_request(f"{app['proxy_target']}/summary", "GET", {}, timeout=8) + if s == 200: + data = json.loads(b) + return {"count": data.get("transactionCount", 0), "totalBalance": data.get("totalBalanceDollars", 0), "spending": data.get("spendingDollars", 0), "income": data.get("incomeDollars", 0), "topCategories": data.get("topCategories", [])[:5], "month": data.get("month", "")} + except: + pass + return None + + def fetch_reader(app): + target = app["proxy_target"] + hdrs = {"X-Auth-Token": MINIFLUX_API_KEY} if MINIFLUX_API_KEY else {} + s1, _, b1 = proxy_request(f"{target}/v1/feeds/counters", "GET", hdrs, timeout=10) + if s1 != 200: + return None + + counters = json.loads(b1) + total_unread = sum(counters.get("unreads", {}).values()) + + entries = [] + s2, _, b2 = proxy_request( + f"{target}/v1/entries?status=unread&direction=desc&order=published_at&limit=10", + "GET", + hdrs, + timeout=10 + ) + if s2 == 200: + payload = json.loads(b2) + for entry in payload.get("entries", [])[:10]: + feed = entry.get("feed") or {} + entries.append({ + "id": entry.get("id"), + "title": entry.get("title") or "Untitled article", + "url": entry.get("url"), + "published_at": entry.get("published_at"), + "author": entry.get("author") or "", + "reading_time": entry.get("reading_time") or 0, + "feed": { + "id": feed.get("id"), + "title": feed.get("title") or "Feed" + } + }) + + return {"unread": total_unread, "articles": entries} + + # Launch all widget fetches in parallel + with ThreadPoolExecutor(max_workers=3) as executor: + for app in apps: + app = dict(app) + svc_token = get_service_token(user["id"], app["id"]) + is_service_level = app["id"] in SERVICE_LEVEL_AUTH + + if not svc_token and not is_service_level: + widgets.append({"app": app["id"], "name": app["name"], "widget": app["dashboard_widget"], "connected": False, "data": None}) + continue + + headers = {"Authorization": f"Bearer {svc_token['auth_token']}"} if svc_token else {} + if not headers and app["id"] == "trips" and TRIPS_API_TOKEN: + headers = {"Authorization": f"Bearer {TRIPS_API_TOKEN}"} + + if app["dashboard_widget"] == "upcoming_trips": + futures[executor.submit(fetch_trips, app, headers)] = app + elif app["dashboard_widget"] == "daily_calories": + futures[executor.submit(fetch_fitness, app, headers)] = app + elif app["dashboard_widget"] == "items_issues": + futures[executor.submit(fetch_inventory, app)] = app + elif app["dashboard_widget"] == "unread_count": + futures[executor.submit(fetch_reader, app)] = app + elif app["dashboard_widget"] == "budget_summary": + futures[executor.submit(fetch_budget, app)] = app + + for future in as_completed(futures): + app = futures[future] + try: + widget_data = future.result() + except Exception as e: + print(f"[Dashboard] Error fetching {app['id']}: {e}") + widget_data = None + widgets.append({"app": app["id"], "name": app["name"], "widget": app["dashboard_widget"], "connected": True, "data": widget_data}) + + # Sort by original app order + app_order = {dict(a)["id"]: dict(a)["sort_order"] for a in apps} + widgets.sort(key=lambda w: app_order.get(w["app"], 99)) + + # Include pinned items + conn2 = get_db() + pinned_rows = conn2.execute( + "SELECT * FROM pinned_items WHERE user_id = ? ORDER BY pinned_at DESC", + (user["id"],) + ).fetchall() + conn2.close() + pinned = [dict(r) for r in pinned_rows] + + self._send_json({"widgets": widgets, "pinned": pinned}) + + # ── Command bar ── + + def _handle_command(self, user, body): + """Parse natural language command and execute it against the right service.""" + try: + data = json.loads(body) + except: + self._send_json({"error": "Invalid JSON"}, 400) + return + + command = data.get("command", "").strip() + if not command: + self._send_json({"error": "No command provided"}, 400) + return + + if not OPENAI_API_KEY: + self._send_json({"error": "AI not configured"}, 500) + return + + # Get context: user's trips list and today's date + trips_token = get_service_token(user["id"], "trips") + fitness_token = get_service_token(user["id"], "fitness") + + trips_context = "" + if trips_token: + s, _, b = proxy_request(f"{TRIPS_URL}/api/trips", "GET", + {"Authorization": f"Bearer {trips_token['auth_token']}"}, timeout=5) + if s == 200: + trips_list = json.loads(b).get("trips", []) + trips_context = "Available trips: " + ", ".join( + f'"{t["name"]}" (id={t["id"]}, {t.get("start_date","")} to {t.get("end_date","")})' for t in trips_list + ) + + today = datetime.now().strftime("%Y-%m-%d") + + system_prompt = f"""You are a command executor for a personal platform with two services: Trips and Fitness. + +Today's date: {today} +Current user: {user["display_name"]} (id={user["id"]}) +{trips_context} + +Parse the user's natural language command and return a JSON action to execute. + +Return ONLY valid JSON with this structure: +{{ + "service": "trips" | "fitness", + "action": "description of what was done", + "api_call": {{ + "method": "POST" | "GET", + "path": "/api/...", + "body": {{ ... }} + }} +}} + +AVAILABLE ACTIONS: + +For Trips service: +- Add location/activity: POST /api/location with {{"trip_id": "...", "name": "...", "category": "attraction|restaurant|cafe|hike|shopping", "visit_date": "YYYY-MM-DD", "start_time": "YYYY-MM-DDTHH:MM:SS", "description": "..."}} +- Add lodging: POST /api/lodging with {{"trip_id": "...", "name": "...", "type": "hotel", "check_in": "YYYY-MM-DDTHH:MM:SS", "check_out": "YYYY-MM-DDTHH:MM:SS", "location": "..."}} +- Add transportation: POST /api/transportation with {{"trip_id": "...", "name": "...", "type": "plane|car|train", "from_location": "...", "to_location": "...", "date": "YYYY-MM-DDTHH:MM:SS", "flight_number": "..."}} +- Add note: POST /api/note with {{"trip_id": "...", "name": "...", "content": "...", "date": "YYYY-MM-DD"}} +- Create trip: POST /api/trip with {{"name": "...", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "description": "..."}} + +For Fitness service: +- Log food (quick add): POST /api/entries with {{"entry_type": "quick_add", "meal_type": "breakfast|lunch|dinner|snack", "snapshot_food_name": "...", "snapshot_quantity": number, "snapshot_calories": number, "snapshot_protein": number, "snapshot_carbs": number, "snapshot_fat": number, "date": "YYYY-MM-DD"}} + IMPORTANT for snapshot_food_name: use just the FOOD NAME without the quantity (e.g. "mini cinnabon" not "1/2 mini cinnabon"). Put the numeric quantity in snapshot_quantity (e.g. 0.5 for "half", 0.75 for "3/4"). +- Search food first then log: If you know exact nutrition, use quick_add. Common foods: banana (105cal, 1g protein, 27g carbs, 0g fat), apple (95cal), egg (78cal, 6g protein, 1g carbs, 5g fat), chicken breast (165cal, 31g protein, 0g carbs, 3.6g fat), rice 1 cup (206cal, 4g protein, 45g carbs, 0g fat), bread slice (79cal, 3g protein, 15g carbs, 1g fat), milk 1 cup (149cal, 8g protein, 12g carbs, 8g fat), coffee black (2cal), oatmeal 1 cup (154cal, 5g protein, 27g carbs, 3g fat). + +For Inventory service (searches NocoDB): +- Search items: GET /search-records?q=... (note: NO /api prefix for inventory) +- If user asks to search inventory, find items, look up orders, check serial numbers — use service "inventory" with GET method + +Guidelines: +- For food logging, default meal_type to "snack" if not specified +- For food logging, use today's date if not specified +- For trips, match the trip name fuzzy (e.g. "colorado" matches "Colorado") +- Use the trip_id from the available trips list +- Default check-in to 3PM, check-out to 11AM for hotels +- Estimate reasonable nutrition values for foods you know +- For inventory searches, use service "inventory" with method GET and path /search-records?q=searchterm +- IMPORTANT: Inventory paths do NOT start with /api/ — use bare paths like /search-records""" + + # Call OpenAI + try: + ai_body = json.dumps({ + "model": OPENAI_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": command} + ], + "max_completion_tokens": 1000, + "temperature": 0.1 + }).encode() + + req = urllib.request.Request( + "https://api.openai.com/v1/chat/completions", + data=ai_body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {OPENAI_API_KEY}" + }, + method="POST" + ) + + with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp: + ai_result = json.loads(resp.read().decode()) + ai_text = ai_result["choices"][0]["message"]["content"] + + # Parse AI response + ai_text = ai_text.strip() + if ai_text.startswith("```json"): + ai_text = ai_text[7:] + if ai_text.startswith("```"): + ai_text = ai_text[3:] + if ai_text.endswith("```"): + ai_text = ai_text[:-3] + + parsed = json.loads(ai_text.strip()) + + except Exception as e: + self._send_json({"error": f"AI parsing failed: {e}"}, 500) + return + + # Execute the action + service = parsed.get("service", "") + api_call = parsed.get("api_call", {}) + action_desc = parsed.get("action", "Unknown action") + + if not api_call or not api_call.get("path"): + self._send_json({"error": "AI could not determine action", "parsed": parsed}, 400) + return + + # Get service token + svc_token = get_service_token(user["id"], service) + if not svc_token and service not in ("inventory",): + self._send_json({"error": f"{service} service not connected"}, 400) + return + + # Build request to service + target = SERVICE_MAP.get(service) + if not target: + self._send_json({"error": f"Unknown service: {service}"}, 400) + return + + method = api_call.get("method", "POST") + path = api_call.get("path", "") + body_data = api_call.get("body", {}) + + headers = {"Content-Type": "application/json"} + if svc_token: + headers["Authorization"] = f"Bearer {svc_token['auth_token']}" + + # For fitness food logging, use the resolve workflow instead of quick_add + if service == "fitness" and body_data.get("entry_type") == "quick_add": + food_name = body_data.get("snapshot_food_name", "") + meal_type = body_data.get("meal_type", "snack") + entry_date = body_data.get("date", datetime.now().strftime("%Y-%m-%d")) + # AI-parsed quantity from the command (more reliable than resolve engine's parser) + ai_quantity = body_data.get("snapshot_quantity") + + if food_name and svc_token: + try: + # Step 1: Resolve the food through the smart resolution engine + resolve_body = json.dumps({ + "raw_phrase": food_name, + "meal_type": meal_type, + "entry_date": entry_date, + "source": "command_bar" + }).encode() + rs, _, rb = proxy_request( + f"{target}/api/foods/resolve", "POST", + {**headers, "Content-Type": "application/json"}, resolve_body, timeout=15 + ) + + if rs == 200: + resolved = json.loads(rb) + res_type = resolved.get("resolution_type") + matched = resolved.get("matched_food") + parsed_food = resolved.get("parsed", {}) + + if matched and res_type in ("matched", "ai_estimated", "confirm"): + food_id = matched.get("id") + # For new foods (ai_estimated): serving was created for the exact request + # e.g. "3/4 cup biryani" → serving = "3/4 cup" at 300 cal → qty = 1.0 + # For existing foods (matched/confirm): use AI quantity as multiplier + # e.g. "half cinnabon" → existing "1 mini cinnabon" at 350 cal → qty = 0.5 + if res_type == "ai_estimated": + quantity = 1.0 + elif ai_quantity and ai_quantity != 1: + quantity = ai_quantity + else: + quantity = 1.0 + + body_data = { + "food_id": food_id, + "meal_type": meal_type, + "quantity": quantity, + "date": entry_date, + "entry_type": "food" + } + + # Use matching serving if available + servings = matched.get("servings", []) + if servings: + body_data["serving_id"] = servings[0].get("id") + + # Use snapshot name override if provided (includes modifiers) + if resolved.get("snapshot_name_override"): + body_data["snapshot_food_name_override"] = resolved["snapshot_name_override"] + if resolved.get("note"): + body_data["note"] = resolved["note"] + + action_desc = f"Logged {matched.get('name', food_name)} for {meal_type}" + if res_type == "ai_estimated": + action_desc += " (AI estimated, added to food database)" + + # Auto-fetch image if food doesn't have one + if not matched.get("image_path") and food_id: + try: + img_body = json.dumps({"query": matched.get("name", food_name) + " food"}).encode() + si, _, sb = proxy_request( + f"{target}/api/images/search", "POST", + {**headers, "Content-Type": "application/json"}, img_body, timeout=8 + ) + if si == 200: + imgs = json.loads(sb).get("images", []) + if imgs: + img_url = imgs[0].get("url") or imgs[0].get("thumbnail") + if img_url: + proxy_request( + f"{target}/api/foods/{food_id}/image", "POST", + {**headers, "Content-Type": "application/json"}, + json.dumps({"url": img_url}).encode(), timeout=10 + ) + except Exception as e: + print(f"[Command] Auto-image fetch failed: {e}") + + except Exception as e: + print(f"[Command] Food resolve failed, falling back to quick_add: {e}") + + req_body = json.dumps(body_data).encode() if body_data else None + + status, resp_headers, resp_body = proxy_request(f"{target}{path}", method, headers, req_body, timeout=15) + + try: + result = json.loads(resp_body) + except: + result = {"raw": resp_body.decode()[:200]} + + self._send_json({ + "success": status < 400, + "action": action_desc, + "service": service, + "status": status, + "result": result + }) + + # ── Service proxy ── + + def _proxy(self, method, path, body=None): + user = self._get_user() + + service_id, target, backend_path = resolve_service(path) + + if not target: + self._send_json({"error": "Unknown service"}, 404) + return + + # Build headers for downstream + headers = {} + ct = self.headers.get("Content-Type") + if ct: + headers["Content-Type"] = ct + + # Inject service auth token if user is authenticated + if service_id == "reader" and MINIFLUX_API_KEY: + headers["X-Auth-Token"] = MINIFLUX_API_KEY + elif service_id == "trips" and TRIPS_API_TOKEN: + headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}" + elif user: + svc_token = get_service_token(user["id"], service_id) + if svc_token: + if svc_token["auth_type"] == "bearer": + headers["Authorization"] = f"Bearer {svc_token['auth_token']}" + elif svc_token["auth_type"] == "cookie": + headers["Cookie"] = f"session={svc_token['auth_token']}" + + # Forward original auth if no service token (allows direct API key usage) + if "Authorization" not in headers: + auth = self.headers.get("Authorization") + if auth: + headers["Authorization"] = auth + + # Forward other useful headers + for h in ["X-API-Key", "X-Telegram-User-Id"]: + val = self.headers.get(h) + if val: + headers[h] = val + + # Build full URL + query = self.path.split("?", 1)[1] if "?" in self.path else "" + full_url = f"{target}{backend_path}" + if query: + full_url += f"?{query}" + + # Proxy the request + status, resp_headers, resp_body = proxy_request(full_url, method, headers, body) + + # Send response + self.send_response(status) + for k, v in resp_headers.items(): + k_lower = k.lower() + if k_lower in ("content-type", "content-disposition"): + self.send_header(k, v) + self.send_header("Content-Length", len(resp_body)) + self.end_headers() + self.wfile.write(resp_body) + + +# ── Main ── + +def main(): + init_db() + load_service_map() + + print(f"[Gateway] Services: {SERVICE_MAP}") + print(f"[Gateway] Listening on port {PORT}") + + server = HTTPServer(("0.0.0.0", PORT), GatewayHandler) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7edb2b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2658 @@ +{ + "name": "platform", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "platform", + "version": "0.1.0", + "workspaces": [ + "frontend", + "packages/*" + ], + "devDependencies": { + "playwright": "^1.58.2" + } + }, + "frontend": { + "name": "platform-frontend", + "version": "0.0.1", + "dependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.2", + "clsx": "^2.1.1", + "daisyui": "^5.5.19", + "gridstack": "^12.4.2", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^1.0.1", + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/node": "^25.5.0", + "bits-ui": "^2.16.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.0.1.tgz", + "integrity": "sha512-iIcE+CDaHtjVKT5UkpnBIRy1b51u29RHgQxrjy5SMTp/KyqbtNfzkWnmWpBoCWKs2RKiF/e8VW+z0dw+wpb4Rw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@platform/api-utils": { + "resolved": "packages/api-utils", + "link": true + }, + "node_modules/@platform/ui-components": { + "resolved": "packages/ui-components", + "link": true + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.3.tgz", + "integrity": "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/gridstack": { + "version": "12.4.2", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-12.4.2.tgz", + "integrity": "sha512-aXbJrQpi3LwpYXYOr4UriPM5uc/dPcjK01SdOE5PDpx2vi8tnLhU7yBg/1i4T59UhNkG/RBfabdFUObuN+gMnw==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.me/alaind831" + }, + { + "type": "venmo", + "url": "https://www.venmo.com/adumesny" + } + ], + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform-frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", + "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, + "packages/api-utils": { + "name": "@platform/api-utils", + "version": "0.1.0" + }, + "packages/ui-components": { + "name": "@platform/ui-components", + "version": "0.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..09495eb --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "platform", + "private": true, + "version": "0.1.0", + "workspaces": [ + "frontend", + "packages/*" + ], + "scripts": { + "dev": "npm --workspace frontend run dev", + "build": "npm --workspace frontend run build", + "check": "npm --workspace frontend run check" + }, + "devDependencies": { + "playwright": "^1.58.2" + } +} diff --git a/packages/api-utils/package.json b/packages/api-utils/package.json new file mode 100644 index 0000000..f3602c7 --- /dev/null +++ b/packages/api-utils/package.json @@ -0,0 +1,7 @@ +{ + "name": "@platform/api-utils", + "private": true, + "version": "0.1.0", + "type": "module" +} + diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json new file mode 100644 index 0000000..4181a1b --- /dev/null +++ b/packages/ui-components/package.json @@ -0,0 +1,6 @@ +{ + "name": "@platform/ui-components", + "private": true, + "version": "0.1.0", + "type": "module" +} diff --git a/services/budget/Dockerfile b/services/budget/Dockerfile new file mode 100644 index 0000000..37dccd6 --- /dev/null +++ b/services/budget/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --production + +COPY server.js ./ + +RUN mkdir -p /app/data + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/services/budget/package-lock.json b/services/budget/package-lock.json new file mode 100644 index 0000000..db6b5ba --- /dev/null +++ b/services/budget/package-lock.json @@ -0,0 +1,1443 @@ +{ + "name": "budget-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "budget-service", + "version": "1.0.0", + "dependencies": { + "@actual-app/api": "^26.3.0", + "cors": "^2.8.5", + "express": "^4.21.2" + } + }, + "node_modules/@actual-app/api": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@actual-app/api/-/api-26.3.0.tgz", + "integrity": "sha512-03OG+udLh5GXG4I4AbfcRNhLT35vRfgHtE1JnkWGRwv6nFHY+KckPOmsDAX50fw7Q3vxPA8usHkG3JyGcRYSew==", + "license": "MIT", + "dependencies": { + "@actual-app/crdt": "^2.1.0", + "better-sqlite3": "^12.6.2", + "compare-versions": "^6.1.1", + "node-fetch": "^3.3.2", + "uuid": "^13.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@actual-app/crdt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@actual-app/crdt/-/crdt-2.1.0.tgz", + "integrity": "sha512-Qb8hMq10Wi2kYIDj0fG4uy00f9Mloghd+xQrHQiPQfgx022VPJ/No+z/bmfj4MuFH8FrPiLysSzRsj2PNQIedw==", + "license": "MIT", + "dependencies": { + "google-protobuf": "^3.12.0-rc.1", + "murmurhash": "^2.0.1", + "uuid": "^9.0.0" + } + }, + "node_modules/@actual-app/crdt/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/murmurhash": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-2.0.1.tgz", + "integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/services/budget/package.json b/services/budget/package.json new file mode 100644 index 0000000..ab15497 --- /dev/null +++ b/services/budget/package.json @@ -0,0 +1,14 @@ +{ + "name": "budget-service", + "version": "1.0.0", + "description": "REST API wrapper for Actual Budget", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@actual-app/api": "^26.3.0", + "cors": "^2.8.5", + "express": "^4.21.2" + } +} diff --git a/services/budget/server.js b/services/budget/server.js new file mode 100644 index 0000000..3e2163a --- /dev/null +++ b/services/budget/server.js @@ -0,0 +1,653 @@ +// Polyfill browser globals that @actual-app/api v26 expects in Node.js +if (typeof globalThis.navigator === 'undefined') { + globalThis.navigator = { platform: 'linux', userAgent: 'node' }; +} + +const express = require('express'); +const cors = require('cors'); +const api = require('@actual-app/api'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const ACTUAL_SERVER_URL = process.env.ACTUAL_SERVER_URL || 'http://actualbudget:5006'; +const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD; +const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID; +const PORT = process.env.PORT || 3001; +const DATA_DIR = process.env.ACTUAL_DATA_DIR || '/app/data'; + +// How often (ms) to silently re-sync with the server in the background. +const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +let ready = false; + +// --------------------------------------------------------------------------- +// Actual Budget connection lifecycle +// --------------------------------------------------------------------------- + +async function initActual() { + console.log(`[budget] Connecting to Actual server at ${ACTUAL_SERVER_URL}`); + await api.init({ + serverURL: ACTUAL_SERVER_URL, + password: ACTUAL_PASSWORD, + dataDir: DATA_DIR, + }); + console.log(`[budget] Downloading budget ${ACTUAL_SYNC_ID}`); + await api.downloadBudget(ACTUAL_SYNC_ID); + ready = true; + console.log('[budget] Budget loaded and ready'); +} + +async function syncBudget() { + try { + await api.sync(); + console.log(`[budget] Background sync completed at ${new Date().toISOString()}`); + } catch (err) { + console.error('[budget] Background sync failed:', err.message); + } +} + +// --------------------------------------------------------------------------- +// Middleware - reject requests until the budget is loaded +// --------------------------------------------------------------------------- + +function requireReady(req, res, next) { + if (!ready) { + return res.status(503).json({ error: 'Budget not loaded yet' }); + } + next(); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Convert cents (integer) to a human-readable dollar amount. */ +function centsToDollars(cents) { + return cents / 100; +} + +/** Get the first and last day of a YYYY-MM month string. */ +function monthBounds(month) { + const [year, m] = month.split('-').map(Number); + const start = `${month}-01`; + const lastDay = new Date(year, m, 0).getDate(); + const end = `${month}-${String(lastDay).padStart(2, '0')}`; + return { start, end }; +} + +/** Current month as YYYY-MM. */ +function currentMonth() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +} + +/** Build lookup maps for payees, accounts, and categories. */ +async function buildLookups() { + const [payees, accounts, categories] = await Promise.all([ + api.getPayees(), + api.getAccounts(), + api.getCategories(), + ]); + const payeeMap = {}; + for (const p of payees) payeeMap[p.id] = p.name; + const accountMap = {}; + for (const a of accounts) accountMap[a.id] = a.name; + const categoryMap = {}; + for (const c of categories) categoryMap[c.id] = c.name; + return { payeeMap, accountMap, categoryMap }; +} + +/** Enrich a transaction with resolved names. */ +function enrichTransaction(t, payeeMap, accountMap, categoryMap) { + return { + ...t, + amountDollars: centsToDollars(t.amount), + payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || t.payee || '', + accountName: t.accountName || accountMap[t.account] || t.account || '', + categoryName: categoryMap[t.category] || null, + }; +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', ready }); +}); + +// ---- Accounts ------------------------------------------------------------- + +app.get('/accounts', requireReady, async (_req, res) => { + try { + const accounts = await api.getAccounts(); + const enriched = await Promise.all( + accounts.map(async (acct) => { + const balance = await api.getAccountBalance(acct.id); + return { + ...acct, + balance, + balanceDollars: centsToDollars(balance), + }; + }), + ); + res.json(enriched); + } catch (err) { + console.error('[budget] GET /accounts error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Transactions (filtered) ---------------------------------------------- + +app.get('/transactions', requireReady, async (req, res) => { + try { + const { accountId, startDate, endDate, limit, offset } = req.query; + + if (!accountId) { + return res.status(400).json({ error: 'accountId query parameter is required' }); + } + + const start = startDate || '1970-01-01'; + const end = endDate || '2099-12-31'; + const { payeeMap, accountMap, categoryMap } = await buildLookups(); + + let txns = await api.getTransactions(accountId, start, end); + + // Sort newest first + txns.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0)); + + const total = txns.length; + const pageOffset = parseInt(offset, 10) || 0; + const pageLimit = parseInt(limit, 10) || 50; + txns = txns.slice(pageOffset, pageOffset + pageLimit); + + res.json({ + total, + offset: pageOffset, + limit: pageLimit, + transactions: txns.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)), + }); + } catch (err) { + console.error('[budget] GET /transactions error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Recent transactions across all accounts ------------------------------ + +app.get('/transactions/recent', requireReady, async (_req, res) => { + try { + const limit = parseInt(_req.query.limit, 10) || 20; + const accounts = await api.getAccounts(); + const { payeeMap, accountMap, categoryMap } = await buildLookups(); + + const daysBack = new Date(); + daysBack.setDate(daysBack.getDate() - 90); + const startDate = daysBack.toISOString().slice(0, 10); + const endDate = new Date().toISOString().slice(0, 10); + + let all = []; + for (const acct of accounts) { + const txns = await api.getTransactions(acct.id, startDate, endDate); + all.push(...txns.map((t) => ({ ...t, accountName: acct.name }))); + } + + all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0)); + all = all.slice(0, limit); + + res.json(all.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap))); + } catch (err) { + console.error('[budget] GET /transactions/recent error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Categories ----------------------------------------------------------- + +app.get('/categories', requireReady, async (_req, res) => { + try { + const groups = await api.getCategoryGroups(); + const categories = await api.getCategories(); + + const grouped = groups.map((g) => ({ + ...g, + categories: categories.filter((c) => c.group_id === g.id), + })); + + res.json(grouped); + } catch (err) { + console.error('[budget] GET /categories error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Budget for a month --------------------------------------------------- + +app.get('/budget/:month', requireReady, async (req, res) => { + try { + const { month } = req.params; + if (!/^\d{4}-\d{2}$/.test(month)) { + return res.status(400).json({ error: 'month must be YYYY-MM format' }); + } + const data = await api.getBudgetMonth(month); + res.json(data); + } catch (err) { + console.error(`[budget] GET /budget/${req.params.month} error:`, err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Update a transaction ------------------------------------------------- + +app.patch('/transactions/:id', requireReady, async (req, res) => { + try { + const { id } = req.params; + const updates = req.body; + + if (!updates || Object.keys(updates).length === 0) { + return res.status(400).json({ error: 'Request body must contain fields to update' }); + } + + await api.updateTransaction(id, updates); + await api.sync(); + + res.json({ success: true, id, updated: updates }); + } catch (err) { + console.error(`[budget] PATCH /transactions/${req.params.id} error:`, err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Make Transfer (link two existing transactions) ----------------------- + +app.post('/make-transfer', requireReady, async (req, res) => { + try { + const { transactionId1, transactionId2 } = req.body; + if (!transactionId1 || !transactionId2) { + return res.status(400).json({ error: 'transactionId1 and transactionId2 are required' }); + } + + // Get payees to find transfer payees for each account + const payees = await api.getPayees(); + const accounts = await api.getAccounts(); + + // We need to figure out which account each transaction belongs to + // Fetch both transactions by getting all recent + searching + const allAccounts = await api.getAccounts(); + let tx1 = null; + let tx2 = null; + + for (const acct of allAccounts) { + const txns = await api.getTransactions(acct.id, '1970-01-01', '2099-12-31'); + if (!tx1) tx1 = txns.find((t) => t.id === transactionId1); + if (!tx2) tx2 = txns.find((t) => t.id === transactionId2); + if (tx1 && tx2) break; + } + + if (!tx1 || !tx2) { + return res.status(404).json({ error: 'Could not find one or both transactions' }); + } + + if (tx1.account === tx2.account) { + return res.status(400).json({ error: 'Both transactions are from the same account' }); + } + + // Per Actual docs: set the transfer payee on ONE transaction. + // Actual auto-creates the counterpart on the other account. + // Then delete the other original transaction (it's now replaced). + + // Pick the outgoing (negative) transaction to keep, delete the incoming one + const keepTx = tx1.amount < 0 ? tx1 : tx2; + const deleteTx = tx1.amount < 0 ? tx2 : tx1; + const keepId = keepTx === tx1 ? transactionId1 : transactionId2; + const deleteId = deleteTx === tx1 ? transactionId1 : transactionId2; + + // Find the transfer payee for the account we're transferring TO (the deleted tx's account) + const targetTransferPayee = payees.find((p) => p.transfer_acct === deleteTx.account); + + if (!targetTransferPayee) { + return res.status(400).json({ error: 'Could not find transfer payee for destination account' }); + } + + // Step 1: Set transfer payee on the kept transaction → Actual creates counterpart + await api.updateTransaction(keepId, { payee: targetTransferPayee.id }); + + // Step 2: Delete the original other transaction (Actual already made a new linked one) + await api.deleteTransaction(deleteId); + + await api.sync(); + + const acctName1 = accounts.find((a) => a.id === keepTx.account)?.name || keepTx.account; + const acctName2 = accounts.find((a) => a.id === deleteTx.account)?.name || deleteTx.account; + + console.log(`[budget] Linked transfer: ${acctName1} <-> ${acctName2} (kept ${keepId}, deleted ${deleteId})`); + res.json({ + success: true, + linked: { transactionId1: keepId, transactionId2: deleteId, account1: acctName1, account2: acctName2 }, + }); + } catch (err) { + console.error('[budget] POST /make-transfer error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Uncategorized count (total across all accounts) ---------------------- + +app.get('/uncategorized-count', requireReady, async (_req, res) => { + try { + const accounts = await api.getAccounts(); + const startDate = '2000-01-01'; + const endDate = new Date().toISOString().slice(0, 10); + + let total = 0; + for (const acct of accounts) { + if (acct.closed) continue; + const txns = await api.getTransactions(acct.id, startDate, endDate); + total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length; + } + res.json({ count: total }); + } catch (err) { + console.error('[budget] GET /uncategorized-count error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Suggested transfers -------------------------------------------------- + +app.get('/suggested-transfers', requireReady, async (_req, res) => { + try { + // Sync first to get latest state + await api.sync().catch(() => {}); + const accounts = await api.getAccounts(); + const { payeeMap, accountMap, categoryMap } = await buildLookups(); + + // Get last 90 days of transactions across all accounts + const daysBack = new Date(); + daysBack.setDate(daysBack.getDate() - 90); + const startDate = daysBack.toISOString().slice(0, 10); + const endDate = new Date().toISOString().slice(0, 10); + + let allTxns = []; + for (const acct of accounts) { + if (acct.closed) continue; + const txns = await api.getTransactions(acct.id, startDate, endDate); + allTxns.push( + ...txns.map((t) => ({ + ...t, + accountName: acct.name, + payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || '', + categoryName: categoryMap[t.category] || null, + amountDollars: centsToDollars(t.amount), + })), + ); + } + + // Only consider transactions that aren't already transfers and aren't categorized + const candidates = allTxns.filter( + (t) => !t.transfer_id && t.amount !== 0 && !t.starting_balance_flag, + ); + + // Payment-related keywords + const paymentKeywords = [ + 'payment', 'credit card', 'crd epay', 'online transfer', + 'transfer from', 'transfer to', 'autopay', 'bill pay', + 'pymt', 'payment received', 'payment thank', + ]; + + function looksLikePayment(t) { + const text = ((t.payeeName || '') + ' ' + (t.notes || '')).toLowerCase(); + return paymentKeywords.some((kw) => text.includes(kw)); + } + + // Build index by absolute amount + const byAmount = {}; + for (const t of candidates) { + const key = Math.abs(t.amount); + if (!byAmount[key]) byAmount[key] = []; + byAmount[key].push(t); + } + + const suggestions = []; + + for (const [amt, group] of Object.entries(byAmount)) { + if (group.length < 2) continue; + + // Find negative (outgoing) and positive (incoming) transactions + const negatives = group.filter((t) => t.amount < 0); + const positives = group.filter((t) => t.amount > 0); + + for (const neg of negatives) { + for (const pos of positives) { + // Must be different accounts + if (neg.account === pos.account) continue; + + // At least one should look like a payment + if (!looksLikePayment(neg) && !looksLikePayment(pos)) continue; + + // Dates should be within 3 days + const d1 = new Date(neg.date); + const d2 = new Date(pos.date); + const daysDiff = Math.abs(d1 - d2) / 86400000; + if (daysDiff > 3) continue; + + // Calculate confidence + let confidence = 60; // base: matching amount + different accounts + if (looksLikePayment(neg) && looksLikePayment(pos)) confidence += 20; + else if (looksLikePayment(neg) || looksLikePayment(pos)) confidence += 10; + if (daysDiff === 0) confidence += 15; + else if (daysDiff <= 1) confidence += 10; + else if (daysDiff <= 2) confidence += 5; + + // Check if payee name matches an account name (strong signal) + const negPayeeLower = (neg.payeeName || '').toLowerCase(); + const posPayeeLower = (pos.payeeName || '').toLowerCase(); + const negAcctLower = (neg.accountName || '').toLowerCase(); + const posAcctLower = (pos.accountName || '').toLowerCase(); + if ( + negPayeeLower.includes(posAcctLower.slice(0, 10)) || + posPayeeLower.includes(negAcctLower.slice(0, 10)) || + negAcctLower.includes(negPayeeLower.slice(0, 10)) || + posAcctLower.includes(posPayeeLower.slice(0, 10)) + ) { + confidence += 15; + } + + suggestions.push({ + confidence: Math.min(confidence, 100), + amount: Math.abs(neg.amount), + amountDollars: Math.abs(neg.amountDollars), + from: { + id: neg.id, + account: neg.accountName, + accountId: neg.account, + payee: neg.payeeName, + date: neg.date, + notes: neg.notes, + }, + to: { + id: pos.id, + account: pos.accountName, + accountId: pos.account, + payee: pos.payeeName, + date: pos.date, + notes: pos.notes, + }, + }); + } + } + } + + // Sort by confidence desc, then amount desc + suggestions.sort((a, b) => b.confidence - a.confidence || b.amount - a.amount); + + // Deduplicate — each transaction should only appear in one suggestion + const used = new Set(); + const deduped = []; + for (const s of suggestions) { + if (used.has(s.from.id) || used.has(s.to.id)) continue; + used.add(s.from.id); + used.add(s.to.id); + deduped.push(s); + } + + res.json(deduped); + } catch (err) { + console.error('[budget] GET /suggested-transfers error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Transfer payees (for "Make Transfer" feature) ------------------------ + +app.get('/transfer-payees', requireReady, async (_req, res) => { + try { + const payees = await api.getPayees(); + const accounts = await api.getAccounts(); + const accountMap = {}; + for (const a of accounts) accountMap[a.id] = a.name; + + // Transfer payees have transfer_acct set — they map to an account + const transferPayees = payees + .filter((p) => p.transfer_acct) + .map((p) => ({ + payeeId: p.id, + payeeName: p.name, + accountId: p.transfer_acct, + accountName: accountMap[p.transfer_acct] || p.name, + })); + + res.json(transferPayees); + } catch (err) { + console.error('[budget] GET /transfer-payees error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Dashboard summary ---------------------------------------------------- + +app.get('/summary', requireReady, async (_req, res) => { + try { + // Total balance across all accounts + const accounts = await api.getAccounts(); + let totalBalance = 0; + for (const acct of accounts) { + totalBalance += await api.getAccountBalance(acct.id); + } + + // Spending this month + const month = currentMonth(); + const { start, end } = monthBounds(month); + + let allTxns = []; + for (const acct of accounts) { + const txns = await api.getTransactions(acct.id, start, end); + allTxns.push(...txns); + } + + // Spending = sum of negative amounts (expenses) + const spending = allTxns + .filter((t) => t.amount < 0) + .reduce((sum, t) => sum + t.amount, 0); + + const income = allTxns + .filter((t) => t.amount > 0) + .reduce((sum, t) => sum + t.amount, 0); + + // Top spending categories + const categories = await api.getCategories(); + const catMap = new Map(categories.map((c) => [c.id, c.name])); + + const byCat = {}; + for (const t of allTxns) { + if (t.amount < 0 && t.category) { + const name = catMap.get(t.category) || 'Uncategorized'; + byCat[name] = (byCat[name] || 0) + t.amount; + } + } + + const topCategories = Object.entries(byCat) + .map(([name, amount]) => ({ name, amount, amountDollars: centsToDollars(amount) })) + .sort((a, b) => a.amount - b.amount) // most negative first + .slice(0, 10); + + res.json({ + month, + totalBalance, + totalBalanceDollars: centsToDollars(totalBalance), + spending, + spendingDollars: centsToDollars(spending), + income, + incomeDollars: centsToDollars(income), + topCategories, + accountCount: accounts.length, + transactionCount: allTxns.length, + }); + } catch (err) { + console.error('[budget] GET /summary error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ---- Force sync ----------------------------------------------------------- + +app.post('/sync', requireReady, async (_req, res) => { + try { + await api.sync(); + res.json({ success: true, syncedAt: new Date().toISOString() }); + } catch (err) { + console.error('[budget] POST /sync error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +async function start() { + if (!ACTUAL_PASSWORD) { + console.error('[budget] ACTUAL_PASSWORD environment variable is required'); + process.exit(1); + } + if (!ACTUAL_SYNC_ID) { + console.error('[budget] ACTUAL_SYNC_ID environment variable is required'); + process.exit(1); + } + + try { + await initActual(); + } catch (err) { + console.error('[budget] Failed to initialise Actual connection:', err); + process.exit(1); + } + + // Periodic background sync + setInterval(syncBudget, SYNC_INTERVAL_MS); + + app.listen(PORT, () => { + console.log(`[budget] Server listening on port ${PORT}`); + }); +} + +// Graceful shutdown +async function shutdown() { + console.log('[budget] Shutting down...'); + try { + await api.shutdown(); + } catch (_) { + // ignore + } + process.exit(0); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +start(); diff --git a/services/fitness/Dockerfile.backend b/services/fitness/Dockerfile.backend new file mode 100644 index 0000000..56dea8f --- /dev/null +++ b/services/fitness/Dockerfile.backend @@ -0,0 +1,5 @@ +FROM python:3.12-slim +WORKDIR /app +COPY server.py . +EXPOSE 8095 +CMD ["python3", "server.py"] diff --git a/services/fitness/docker-compose.yml b/services/fitness/docker-compose.yml new file mode 100644 index 0000000..ec3d543 --- /dev/null +++ b/services/fitness/docker-compose.yml @@ -0,0 +1,28 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: calorietracker-backend + restart: unless-stopped + ports: + - "8095:8095" + volumes: + - ./data:/app/data + env_file: .env + environment: + - PORT=8095 + - DATA_DIR=/app/data + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: calorietracker-frontend + restart: unless-stopped + ports: + - "8096:3000" + environment: + - VITE_API_URL=http://calorietracker-backend:8095 + depends_on: + - backend diff --git a/services/fitness/frontend-legacy/Dockerfile b/services/fitness/frontend-legacy/Dockerfile new file mode 100644 index 0000000..b0e6baa --- /dev/null +++ b/services/fitness/frontend-legacy/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-slim +WORKDIR /app +COPY --from=build /app/build ./build +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "build"] diff --git a/services/fitness/frontend-legacy/package-lock.json b/services/fitness/frontend-legacy/package-lock.json new file mode 100644 index 0000000..e1a3b14 --- /dev/null +++ b/services/fitness/frontend-legacy/package-lock.json @@ -0,0 +1,2320 @@ +{ + "name": "calorietracker-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calorietracker-frontend", + "version": "0.0.1", + "dependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.2", + "daisyui": "^5.5.19", + "tailwindcss": "^4.2.2" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", + "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/services/fitness/frontend-legacy/package.json b/services/fitness/frontend-legacy/package.json new file mode 100644 index 0000000..efe2ddd --- /dev/null +++ b/services/fitness/frontend-legacy/package.json @@ -0,0 +1,28 @@ +{ + "name": "calorietracker-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.2", + "daisyui": "^5.5.19", + "tailwindcss": "^4.2.2" + } +} diff --git a/services/fitness/frontend-legacy/src/app.css b/services/fitness/frontend-legacy/src/app.css new file mode 100644 index 0000000..8b6cb62 --- /dev/null +++ b/services/fitness/frontend-legacy/src/app.css @@ -0,0 +1,25 @@ +@import 'tailwindcss'; +@plugin 'daisyui' { + themes: night, dim, light; +} + +:root { + --header-height: 4rem; +} + +* { + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: oklch(0.2 0.02 260); } +::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 260); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: oklch(0.5 0.02 260); } + +/* Card hover elevation */ +.card-hover { + transition: box-shadow 0.3s ease, border-color 0.3s ease; +} +.card-hover:hover { + box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1); +} diff --git a/services/fitness/frontend-legacy/src/app.d.ts b/services/fitness/frontend-legacy/src/app.d.ts new file mode 100644 index 0000000..6285566 --- /dev/null +++ b/services/fitness/frontend-legacy/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/services/fitness/frontend-legacy/src/app.html b/services/fitness/frontend-legacy/src/app.html new file mode 100644 index 0000000..01d589b --- /dev/null +++ b/services/fitness/frontend-legacy/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/services/fitness/frontend-legacy/src/hooks.server.ts b/services/fitness/frontend-legacy/src/hooks.server.ts new file mode 100644 index 0000000..4fac369 --- /dev/null +++ b/services/fitness/frontend-legacy/src/hooks.server.ts @@ -0,0 +1,39 @@ +import type { Handle } from '@sveltejs/kit'; + +const API_BACKEND = process.env.VITE_API_URL || 'http://localhost:8095'; + +export const handle: Handle = async ({ event, resolve }) => { + if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) { + const targetUrl = `${API_BACKEND}${event.url.pathname}${event.url.search}`; + + const headers = new Headers(); + for (const [key, value] of event.request.headers.entries()) { + if (['authorization', 'content-type', 'cookie', 'x-api-key', 'x-telegram-user-id'].includes(key.toLowerCase())) { + headers.set(key, value); + } + } + + try { + const response = await fetch(targetUrl, { + method: event.request.method, + headers, + body: event.request.method !== 'GET' && event.request.method !== 'HEAD' + ? await event.request.arrayBuffer() + : undefined, + }); + + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + } catch (err) { + console.error('Proxy error:', err); + return new Response(JSON.stringify({ error: 'Backend unavailable' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + return resolve(event); +}; diff --git a/services/fitness/frontend-legacy/src/lib/api/client.ts b/services/fitness/frontend-legacy/src/lib/api/client.ts new file mode 100644 index 0000000..28ccf2b --- /dev/null +++ b/services/fitness/frontend-legacy/src/lib/api/client.ts @@ -0,0 +1,81 @@ +const API_BASE = import.meta.env.VITE_API_URL || ''; + +export function getToken(): string | null { + return typeof window !== 'undefined' ? localStorage.getItem('session_token') : null; +} + +export function hasToken(): boolean { + return !!getToken(); +} + +export async function api(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + + if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.href = '/login'; + throw new Error('No token'); + } + + const headers: Record = { + ...(options.headers as Record || {}) + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (options.body && typeof options.body === 'string') { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: 'include' + }); + + if (res.status === 401) { + if (typeof window !== 'undefined') { + localStorage.removeItem('session_token'); + window.location.href = '/login'; + } + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || `HTTP ${res.status}`); + } + + return res.json(); +} + +export function get(path: string) { + return api(path); +} + +export function post(path: string, data: unknown) { + return api(path, { method: 'POST', body: JSON.stringify(data) }); +} + +export function patch(path: string, data: unknown) { + return api(path, { method: 'PATCH', body: JSON.stringify(data) }); +} + +export function put(path: string, data: unknown) { + return api(path, { method: 'PUT', body: JSON.stringify(data) }); +} + +export function del(path: string) { + return api(path, { method: 'DELETE' }); +} + +export function today(): string { + return new Date().toISOString().split('T')[0]; +} + +export function formatDate(d: string): string { + return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric' + }); +} diff --git a/services/fitness/frontend-legacy/src/lib/api/types.ts b/services/fitness/frontend-legacy/src/lib/api/types.ts new file mode 100644 index 0000000..02cbea6 --- /dev/null +++ b/services/fitness/frontend-legacy/src/lib/api/types.ts @@ -0,0 +1,165 @@ +export interface User { + id: string; + username: string; + display_name: string; + telegram_user_id?: string; +} + +export interface Food { + id: string; + name: string; + brand?: string; + barcode?: string; + base_unit: string; + calories_per_base: number; + protein_per_base: number; + carbs_per_base: number; + fat_per_base: number; + status: string; + image_path?: string; + servings: FoodServing[]; + aliases?: FoodAlias[]; + score?: number; + match_type?: string; +} + +export interface FoodServing { + id: string; + food_id: string; + name: string; + amount_in_base: number; + is_default: number; +} + +export interface FoodAlias { + id: string; + food_id: string; + alias: string; + alias_normalized: string; +} + +export interface FoodEntry { + id: string; + user_id: string; + food_id?: string; + meal_type: string; + entry_date: string; + entry_type: string; + quantity: number; + unit: string; + serving_description?: string; + snapshot_food_name: string; + snapshot_serving_label?: string; + snapshot_grams?: number; + snapshot_calories: number; + snapshot_protein: number; + snapshot_carbs: number; + snapshot_fat: number; + source: string; + entry_method: string; + raw_text?: string; + confidence_score?: number; + note?: string; + food_image_path?: string; + created_at: string; +} + +export interface DailyTotals { + total_calories: number; + total_protein: number; + total_carbs: number; + total_fat: number; + entry_count: number; +} + +export interface Goal { + id: string; + user_id: string; + start_date: string; + end_date?: string; + calories: number; + protein: number; + carbs: number; + fat: number; + is_active: number; +} + +export interface MealTemplate { + id: string; + user_id: string; + name: string; + meal_type?: string; + is_favorite: number; + items: MealTemplateItem[]; +} + +export interface MealTemplateItem { + id: string; + food_id: string; + quantity: number; + unit: string; + snapshot_food_name: string; + snapshot_calories: number; + snapshot_protein: number; + snapshot_carbs: number; + snapshot_fat: number; +} + +export interface QueueItem { + id: string; + user_id: string; + raw_text: string; + proposed_food_id?: string; + candidates_json?: string; + confidence: number; + meal_type?: string; + entry_date?: string; + source?: string; + created_at: string; +} + +export interface ExternalFood { + name: string; + brand?: string; + barcode?: string; + calories_per_100g: number; + protein_per_100g: number; + carbs_per_100g: number; + fat_per_100g: number; + serving_size_text?: string; + serving_grams?: number; + source: string; + relevance_score?: number; +} + +export interface ResolveResult { + resolution_type: 'matched' | 'confirm' | 'queued' | 'quick_add' | 'ai_estimated' | 'external_match'; + confidence: number; + matched_food?: Food; + candidate_foods: Food[]; + external_results: ExternalFood[]; + ai_estimate?: Record; + parsed: { + quantity: number; + unit: string; + food_description: string; + meal_type?: string; + brand?: string; + modifiers?: string; + exclusions?: string; + }; + raw_text: string; + queue_id?: string; + reason?: string; +} + +export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; + +export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack']; + +export const MEAL_LABELS: Record = { + breakfast: 'B', + lunch: 'L', + dinner: 'D', + snack: 'S' +}; diff --git a/services/fitness/frontend-legacy/src/lib/components/AddFoodModal.svelte b/services/fitness/frontend-legacy/src/lib/components/AddFoodModal.svelte new file mode 100644 index 0000000..c79b713 --- /dev/null +++ b/services/fitness/frontend-legacy/src/lib/components/AddFoodModal.svelte @@ -0,0 +1,270 @@ + + + diff --git a/services/fitness/frontend-legacy/src/lib/components/MacroBar.svelte b/services/fitness/frontend-legacy/src/lib/components/MacroBar.svelte new file mode 100644 index 0000000..12aeeff --- /dev/null +++ b/services/fitness/frontend-legacy/src/lib/components/MacroBar.svelte @@ -0,0 +1,23 @@ + + +
+
{label}
+
+ {Math.round(value)} + / {Math.round(goal)}{unit} +
+ +
+ {#if over}{Math.round(value - goal)}{unit} over{:else}{Math.round(remaining)}{unit} left{/if} +
+
diff --git a/services/fitness/frontend-legacy/src/lib/components/Navbar.svelte b/services/fitness/frontend-legacy/src/lib/components/Navbar.svelte new file mode 100644 index 0000000..e052cbc --- /dev/null +++ b/services/fitness/frontend-legacy/src/lib/components/Navbar.svelte @@ -0,0 +1,84 @@ + + +
+ +{#if mobileOpen} +
+ {#each navItems as item} + mobileOpen = false}>{item.label} + {/each} +
+{/if} +
diff --git a/services/fitness/frontend-legacy/src/routes/+layout.svelte b/services/fitness/frontend-legacy/src/routes/+layout.svelte new file mode 100644 index 0000000..6daf14c --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/+layout.svelte @@ -0,0 +1,37 @@ + + + + Calorie Tracker + + +
+ {#if !page.url.pathname.startsWith('/login')} + + {/if} +
+ {@render children()} +
+
diff --git a/services/fitness/frontend-legacy/src/routes/+page.svelte b/services/fitness/frontend-legacy/src/routes/+page.svelte new file mode 100644 index 0000000..6b37fdd --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/+page.svelte @@ -0,0 +1,297 @@ + + +
+ +
+

+ {#if isToday}Today{:else}{formatDate(selectedDate)}{/if} +

+
+ + + + {#if !isToday} + + {/if} +
+ +
+
+ + {#if loading} +
+ {:else} + + {#if queueCount > 0} + +
+
{queueCount} food{queueCount > 1 ? 's' : ''} need review
+
Tap to resolve
+
+ +
+ {/if} + + + {@const g = goal} +
+
+
+
+
+ +
+
+
{Math.round(totals.total_calories)}
+
Calories
+
+
+ {#if g} + +
{Math.round(Math.max(g.calories - totals.total_calories, 0))} left of {Math.round(g.calories)}
+ {/if} +
+
+
+
+
+
+ +
+
+
{Math.round(totals.total_protein)}g
+
Protein
+
+
+ {#if g} + +
{Math.round(Math.max(g.protein - totals.total_protein, 0))}g left of {Math.round(g.protein)}g
+ {/if} +
+
+
+
+
+
+ +
+
+
{Math.round(totals.total_carbs)}g
+
Carbs
+
+
+ {#if g} + +
{Math.round(Math.max(g.carbs - totals.total_carbs, 0))}g left of {Math.round(g.carbs)}g
+ {/if} +
+
+
+
+
+
+ +
+
+
{Math.round(totals.total_fat)}g
+
Fat
+
+
+ {#if g} + +
{Math.round(Math.max(g.fat - totals.total_fat, 0))}g left of {Math.round(g.fat)}g
+ {/if} +
+
+
+ + {#if !g} +
+ Set goals to see progress bars +
+ {/if} + + + {#each MEAL_TYPES as meal} + {@const mealEntries = entriesByMeal(meal)} + {@const mCal = mealCalories(meal)} + {@const mPro = mealProtein(meal)} + {@const expanded = expandedMeals.has(meal)} + +
+ + + + {#if expanded} +
+ {#if mealEntries.length > 0} + {#each mealEntries as entry} +
+ {#if entry.food_image_path} +
+ +
+
+
+
{entry.snapshot_food_name}
+
+ {entry.serving_description || `${entry.quantity} ${entry.unit}`} + {#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'} + AI + {/if} +
+ {#if entry.note} +
{entry.note}
+ {/if} +
+
+
{Math.round(entry.snapshot_calories)}
+
cal
+
+
+ +
+ {:else} +
+
+
{entry.snapshot_food_name}
+
+ {entry.serving_description || `${entry.quantity} ${entry.unit}`} + {#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'} + AI + {/if} + {#if entry.entry_method === 'quick_add'} + quick + {/if} +
+ {#if entry.note} +
{entry.note}
+ {/if} +
+
+
+
{Math.round(entry.snapshot_calories)}
+
cal
+
+ +
+
+ {/if} +
+ {/each} + {:else} +
No entries
+ {/if} + +
+ {/if} +
+ {/each} + {/if} +
+ + + + +{#if showAddModal} + { showAddModal = false; loadDay(); }} onClose={() => showAddModal = false} /> +{/if} diff --git a/services/fitness/frontend-legacy/src/routes/admin/+page.svelte b/services/fitness/frontend-legacy/src/routes/admin/+page.svelte new file mode 100644 index 0000000..33a604d --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/admin/+page.svelte @@ -0,0 +1,171 @@ + + +
+
+

Admin

+

Review queue and manage duplicates

+
+ +
+ + +
+ + {#if tab === 'queue'} + {#if loading} +
+ {:else if queue.length === 0} +
+
+ +
+
No items to review
+
All caught up!
+
+ {:else} +
+ {#each queue as item} + {@const candidates = parseCandidates(item.candidates_json)} +
+
+
+
"{item.raw_text}"
+
+ {#if item.source}{item.source}{/if} + confidence: {(item.confidence * 100).toFixed(0)}% + {#if item.meal_type}{item.meal_type}{/if} + {#if item.entry_date}{item.entry_date}{/if} +
+
+ +
+ + {#if candidates.length > 0} +
Match to existing
+
+ {#each candidates as c} + + {/each} +
+ {:else} +
No candidates — create a new food manually from the Foods page
+ {/if} +
+ {/each} +
+ {/if} + + {:else} +
+
+
+ +
+
+

Merge Duplicate Foods

+

Source gets archived. Entries and aliases move to target.

+
+
+ +
+
+
Source (will be archived)
+
+ + +
+ {#if mergeSource} +
{mergeSource.name}
+ {/if} + {#each mergeSourceResults as f} + + {/each} +
+ +
+
Target (will keep)
+
+ + +
+ {#if mergeTarget} +
{mergeTarget.name}
+ {/if} + {#each mergeTargetResults as f} + + {/each} +
+
+ + +
+ {/if} +
diff --git a/services/fitness/frontend-legacy/src/routes/foods/+page.svelte b/services/fitness/frontend-legacy/src/routes/foods/+page.svelte new file mode 100644 index 0000000..7d95cde --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/foods/+page.svelte @@ -0,0 +1,338 @@ + + +
+
+
+

Food Library

+

{allFoods.length} foods saved

+
+ +
+ +
+ + +
+ + {#if tab === 'all'} +
+ + +
+ {#if loading} +
+ {:else} +
+ {#each filtered as food} +
+
+
+ {food.name} + {#if food.status === 'ai_created'}AI{/if} + {#if food.status === 'needs_review'}Review{/if} +
+ {#if food.brand}
{food.brand}
{/if} +
+ {food.calories_per_base} cal + {food.protein_per_base}g P + {food.carbs_per_base}g C + {food.fat_per_base}g F + per {food.base_unit} +
+
+
+ + +
+
+ {/each} + {#if filtered.length === 0} +
+ {#if query.trim()}No foods match "{query}"{:else}No foods yet{/if} +
+ {/if} +
+ {/if} + {:else} +
+ {#each favorites as food} +
+
+
{food.name}
+
{food.calories_per_base} cal/{food.base_unit}
+
+ +
+ {/each} + {#if favorites.length === 0} +
No favorites yet
+ {/if} +
+ {/if} +
+ + +{#if showCreate} + +{/if} + + +{#if editFood} + +{/if} diff --git a/services/fitness/frontend-legacy/src/routes/goals/+page.svelte b/services/fitness/frontend-legacy/src/routes/goals/+page.svelte new file mode 100644 index 0000000..70e1eaa --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/goals/+page.svelte @@ -0,0 +1,136 @@ + + +
+
+
+

Goals

+

Daily nutrition targets

+
+ +
+ + {#if loading} +
+ {:else} + +
+
+
+ +
+

Set Goals for {userName(selectedUser)}

+
+
+
+
Start Date
+ +
+
+
+
Calories
+ +
+
+
Protein (g)
+ +
+
+
Carbs (g)
+ +
+
+
Fat (g)
+ +
+
+ +
+
+ + + {#if goals.length > 0} +
+
+

History

+
+
+ {#each goals as g} +
+
+
+
+ {g.start_date}{g.end_date ? ` — ${g.end_date}` : ''} +
+
+ {g.calories} cal + {g.protein}g P + {g.carbs}g C + {g.fat}g F +
+
+ {#if g.is_active} + Active + {:else} + Ended + {/if} +
+
+ {/each} +
+ {/if} + {/if} +
diff --git a/services/fitness/frontend-legacy/src/routes/login/+page.svelte b/services/fitness/frontend-legacy/src/routes/login/+page.svelte new file mode 100644 index 0000000..7455932 --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/login/+page.svelte @@ -0,0 +1,65 @@ + + +
+
+
+
+
+ + + + + +
+

Calorie Tracker

+

Sign in to continue

+
+ + {#if error} +
{error}
+ {/if} + +
{ e.preventDefault(); handleLogin(); }}> +
+
Username
+ +
+
+
Password
+ +
+ +
+
+
+
diff --git a/services/fitness/frontend-legacy/src/routes/templates/+page.svelte b/services/fitness/frontend-legacy/src/routes/templates/+page.svelte new file mode 100644 index 0000000..c2a7980 --- /dev/null +++ b/services/fitness/frontend-legacy/src/routes/templates/+page.svelte @@ -0,0 +1,101 @@ + + +
+
+

Meal Templates

+

Save and reuse your favorite meals

+
+ +
+ Log to: + + +
+ + {#if loading} +
+ {:else if templates.length === 0} +
+
+ +
+
No templates yet
+
Templates can be created from the daily log or via API
+
+ {:else} +
+ {#each templates as t} +
+
+
+ {t.name} + {#if t.meal_type}{t.meal_type}{/if} + {#if t.is_favorite}{/if} +
+
+ + +
+
+ {#if t.items?.length > 0} +
+ {#each t.items as item} +
+ {item.snapshot_food_name} x{item.quantity} + {Math.round(item.snapshot_calories * item.quantity)} cal +
+ {/each} +
+ Total + {Math.round(t.items.reduce((s, i) => s + i.snapshot_calories * i.quantity, 0))} cal +
+
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/services/fitness/frontend-legacy/svelte.config.js b/services/fitness/frontend-legacy/svelte.config.js new file mode 100644 index 0000000..ad116dc --- /dev/null +++ b/services/fitness/frontend-legacy/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/services/fitness/frontend-legacy/tsconfig.json b/services/fitness/frontend-legacy/tsconfig.json new file mode 100644 index 0000000..feea18b --- /dev/null +++ b/services/fitness/frontend-legacy/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/services/fitness/frontend-legacy/vite.config.ts b/services/fitness/frontend-legacy/vite.config.ts new file mode 100644 index 0000000..690ceed --- /dev/null +++ b/services/fitness/frontend-legacy/vite.config.ts @@ -0,0 +1,19 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8095', + changeOrigin: true + }, + '/images': { + target: 'http://localhost:8095', + changeOrigin: true + } + } + } +}); diff --git a/services/fitness/server.py b/services/fitness/server.py new file mode 100644 index 0000000..074b098 --- /dev/null +++ b/services/fitness/server.py @@ -0,0 +1,2695 @@ +#!/usr/bin/env python3 +""" +Calorie Tracker - Self-hosted calorie & macro tracker with SQLite backend +Replaces SparkyFitness with fuzzy food matching, confidence-based resolve, and AI intake support. +""" + +import os +import json +import sqlite3 +import uuid +import hashlib +import secrets +import re +import unicodedata +from http.server import HTTPServer, BaseHTTPRequestHandler +from http.cookies import SimpleCookie +from datetime import datetime, date, timedelta +from pathlib import Path +from urllib.parse import urlparse, parse_qs +from difflib import SequenceMatcher +import urllib.request +import urllib.error +import logging + +logger = logging.getLogger(__name__) + +# Configuration +PORT = int(os.environ.get("PORT", 8095)) +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DB_PATH = DATA_DIR / "calories.db" +IMAGES_DIR = DATA_DIR / "images" +API_KEY = os.environ.get("CALORIES_API_KEY", "") # For service-to-service (Telegram) +USDA_API_KEY = os.environ.get("USDA_API_KEY", "") # Free from https://fdc.nal.usda.gov/api-key-signup.html +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") # For AI nutrition estimation +OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") # Fast + cheap for estimation +GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "") # For food image search +GOOGLE_CX = os.environ.get("GOOGLE_CX", "") # Google Custom Search engine ID + +# Ensure directories exist +DATA_DIR.mkdir(parents=True, exist_ok=True) +IMAGES_DIR.mkdir(parents=True, exist_ok=True) + +# ─── Database ──────────────────────────────────────────────────────────────── + +def get_db(): + """Get database connection.""" + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def normalize_food_name(name: str) -> str: + """Normalize a food name for matching: lowercase, strip, collapse whitespace, remove accents.""" + if not name: + return "" + # Remove accents + name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii') + # Lowercase, strip, collapse whitespace + name = re.sub(r'\s+', ' ', name.lower().strip()) + # Remove common filler words for matching + name = re.sub(r'\b(the|a|an|of|with|and|in)\b', '', name).strip() + name = re.sub(r'\s+', ' ', name) + return name + + +def _naive_singularize(name: str) -> str: + """Strip common plural suffixes for search retry. Conservative — only trailing 's'.""" + lower = name.lower().strip() + if lower.endswith('ies'): + return name[:-3] + 'y' # berries -> berry + if lower.endswith('s') and not lower.endswith('ss') and len(lower) > 3: + return name[:-1] + return name + + +def tokenize_food_name(name: str) -> set: + """Break a normalized food name into tokens for overlap matching.""" + return set(normalize_food_name(name).split()) + + +def similarity_score(a: str, b: str) -> float: + """Compute similarity between two food names (0.0 to 1.0).""" + norm_a = normalize_food_name(a) + norm_b = normalize_food_name(b) + + # Exact normalized match + if norm_a == norm_b: + return 1.0 + + # Token overlap (Jaccard) + tokens_a = tokenize_food_name(a) + tokens_b = tokenize_food_name(b) + if tokens_a and tokens_b: + jaccard = len(tokens_a & tokens_b) / len(tokens_a | tokens_b) + else: + jaccard = 0.0 + + # Sequence similarity + seq = SequenceMatcher(None, norm_a, norm_b).ratio() + + # Weighted combination + return max(jaccard * 0.6 + seq * 0.4, seq) + + +def init_db(): + """Initialize database schema.""" + conn = get_db() + cursor = conn.cursor() + + # ── Users ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + telegram_user_id TEXT UNIQUE, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # ── Sessions ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + ''') + + # ── Foods (master records, nutrition per_100g as base) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS foods ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + brand TEXT, + brand_normalized TEXT, + barcode TEXT, + notes TEXT, + -- Nutrition per base unit (per_100g for weight-based, per serving for countable items) + calories_per_base REAL NOT NULL DEFAULT 0, + protein_per_base REAL NOT NULL DEFAULT 0, + carbs_per_base REAL NOT NULL DEFAULT 0, + fat_per_base REAL NOT NULL DEFAULT 0, + -- Base unit: "100g" for weight-based foods, or "piece"/"slice"/"serving" etc for countable + base_unit TEXT NOT NULL DEFAULT '100g', + -- Status: confirmed, ai_created, needs_review, archived + status TEXT NOT NULL DEFAULT 'confirmed', + created_by_user_id TEXT, + is_shared INTEGER NOT NULL DEFAULT 1, + image_path TEXT, -- Filename in data/images/ + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) + ) + ''') + + # ── Food servings (named portion definitions for a food) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS food_servings ( + id TEXT PRIMARY KEY, + food_id TEXT NOT NULL, + name TEXT NOT NULL, -- e.g. "1 cup", "1 slice", "small bowl", "medium plate" + -- How much of the base unit this serving represents + -- For base_unit=100g: amount_in_base=1.5 means 150g + -- For base_unit=piece: amount_in_base=1 means 1 piece + amount_in_base REAL NOT NULL DEFAULT 1.0, + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (food_id) REFERENCES foods(id) ON DELETE CASCADE + ) + ''') + + # ── Food aliases (for fuzzy matching) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS food_aliases ( + id TEXT PRIMARY KEY, + food_id TEXT NOT NULL, + alias TEXT NOT NULL, + alias_normalized TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (food_id) REFERENCES foods(id) ON DELETE CASCADE, + UNIQUE(alias_normalized) + ) + ''') + + # ── Food entries (daily log with immutable snapshots) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS food_entries ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + food_id TEXT, -- Reference to food (nullable for quick-add) + -- Meal type: breakfast, lunch, dinner, snack + meal_type TEXT NOT NULL, + entry_date TEXT NOT NULL, -- YYYY-MM-DD + -- Entry type: food (normal), quick_add (calories only) + entry_type TEXT NOT NULL DEFAULT 'food', + -- Quantity and unit at log time + quantity REAL NOT NULL DEFAULT 1.0, + unit TEXT NOT NULL DEFAULT 'serving', + serving_description TEXT, -- e.g. "1 medium bowl", "2 slices" + -- IMMUTABLE SNAPSHOT: full nutrition + context at time of logging + snapshot_food_name TEXT NOT NULL, + snapshot_serving_label TEXT, -- e.g. "1 breast (170g)", "1 cup" + snapshot_grams REAL, -- estimated grams if known + snapshot_calories REAL NOT NULL DEFAULT 0, + snapshot_protein REAL NOT NULL DEFAULT 0, + snapshot_carbs REAL NOT NULL DEFAULT 0, + snapshot_fat REAL NOT NULL DEFAULT 0, + -- Source & method + source TEXT NOT NULL DEFAULT 'web', -- where: web, telegram, api + entry_method TEXT NOT NULL DEFAULT 'manual', -- how: manual, search, template, ai_plate, ai_label, quick_add + raw_text TEXT, -- Original text from Telegram/AI + confidence_score REAL, -- 0.0 to 1.0 from resolve + note TEXT, -- User note + image_ref TEXT, -- Reference to uploaded image (AI photo logging) + -- AI metadata (JSON blob if from AI) + ai_metadata TEXT, + -- Idempotency: prevent duplicate entries from retries + idempotency_key TEXT UNIQUE, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (food_id) REFERENCES foods(id) + ) + ''') + + # ── Goals (date-ranged) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS goals ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + start_date TEXT NOT NULL, -- YYYY-MM-DD + end_date TEXT, -- NULL = still active + calories REAL NOT NULL DEFAULT 2000, + protein REAL NOT NULL DEFAULT 150, + carbs REAL NOT NULL DEFAULT 200, + fat REAL NOT NULL DEFAULT 65, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + ''') + + # ── Meal templates ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS meal_templates ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + meal_type TEXT, -- Optional default meal type + is_favorite INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS meal_template_items ( + id TEXT PRIMARY KEY, + template_id TEXT NOT NULL, + food_id TEXT NOT NULL, + quantity REAL NOT NULL DEFAULT 1.0, + unit TEXT NOT NULL DEFAULT 'serving', + serving_description TEXT, + -- Snapshot for display (so template shows correct info even if food changes) + snapshot_food_name TEXT NOT NULL, + snapshot_calories REAL NOT NULL DEFAULT 0, + snapshot_protein REAL NOT NULL DEFAULT 0, + snapshot_carbs REAL NOT NULL DEFAULT 0, + snapshot_fat REAL NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (template_id) REFERENCES meal_templates(id) ON DELETE CASCADE, + FOREIGN KEY (food_id) REFERENCES foods(id) + ) + ''') + + # ── Food resolution queue (for low-confidence matches) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS food_resolution_queue ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + raw_text TEXT NOT NULL, + proposed_food_id TEXT, + candidates_json TEXT, -- JSON array of {food_id, name, score} + confidence REAL NOT NULL DEFAULT 0, + meal_type TEXT, + entry_date TEXT, + quantity REAL, + unit TEXT, + source TEXT, + -- Resolution + resolved_food_id TEXT, + resolved_at TEXT, + resolution_action TEXT, -- matched, created, merged, dismissed + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (proposed_food_id) REFERENCES foods(id), + FOREIGN KEY (resolved_food_id) REFERENCES foods(id) + ) + ''') + + # ── User favorites ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_favorites ( + user_id TEXT NOT NULL, + food_id TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, food_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (food_id) REFERENCES foods(id) ON DELETE CASCADE + ) + ''') + + # ── Audit log (merges, resolutions, AI edits) ── + cursor.execute(''' + CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + user_id TEXT, + action TEXT NOT NULL, -- food_merged, queue_resolved, entry_ai_edited, food_archived, food_created + entity_type TEXT NOT NULL, -- food, entry, queue, template + entity_id TEXT, + details TEXT, -- JSON blob with action-specific details + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + ''') + + # ── FTS5 virtual table for food search ── + cursor.execute(''' + CREATE VIRTUAL TABLE IF NOT EXISTS foods_fts USING fts5( + name, + normalized_name, + brand, + content=foods, + content_rowid=rowid + ) + ''') + + # ── FTS5 for aliases ── + cursor.execute(''' + CREATE VIRTUAL TABLE IF NOT EXISTS aliases_fts USING fts5( + alias, + alias_normalized, + content=food_aliases, + content_rowid=rowid + ) + ''') + + # ── Indexes ── + cursor.execute('CREATE INDEX IF NOT EXISTS idx_foods_normalized ON foods(normalized_name)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_foods_status ON foods(status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_foods_brand_norm ON foods(brand_normalized)') + cursor.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_foods_barcode_unique ON foods(barcode) WHERE barcode IS NOT NULL') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_aliases_normalized ON food_aliases(alias_normalized)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_entries_user_date ON food_entries(user_id, entry_date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_entries_user_meal ON food_entries(user_id, meal_type)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_entries_food ON food_entries(food_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_entries_food_date ON food_entries(food_id, entry_date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_entries_idempotency ON food_entries(idempotency_key) WHERE idempotency_key IS NOT NULL') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_goals_user_date ON goals(user_id, start_date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_goals_user_active ON goals(user_id, is_active)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_resolution_queue_user ON food_resolution_queue(user_id, resolved_at)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_telegram ON users(telegram_user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_templates_user ON meal_templates(user_id, is_archived)') + + # ── Triggers to keep FTS5 in sync ── + cursor.executescript(''' + CREATE TRIGGER IF NOT EXISTS foods_ai AFTER INSERT ON foods BEGIN + INSERT INTO foods_fts(rowid, name, normalized_name, brand) + VALUES (new.rowid, new.name, new.normalized_name, new.brand); + END; + + CREATE TRIGGER IF NOT EXISTS foods_ad AFTER DELETE ON foods BEGIN + INSERT INTO foods_fts(foods_fts, rowid, name, normalized_name, brand) + VALUES ('delete', old.rowid, old.name, old.normalized_name, old.brand); + END; + + CREATE TRIGGER IF NOT EXISTS foods_au AFTER UPDATE ON foods BEGIN + INSERT INTO foods_fts(foods_fts, rowid, name, normalized_name, brand) + VALUES ('delete', old.rowid, old.name, old.normalized_name, old.brand); + INSERT INTO foods_fts(rowid, name, normalized_name, brand) + VALUES (new.rowid, new.name, new.normalized_name, new.brand); + END; + + CREATE TRIGGER IF NOT EXISTS aliases_ai AFTER INSERT ON food_aliases BEGIN + INSERT INTO aliases_fts(rowid, alias, alias_normalized) + VALUES (new.rowid, new.alias, new.alias_normalized); + END; + + CREATE TRIGGER IF NOT EXISTS aliases_ad AFTER DELETE ON food_aliases BEGIN + INSERT INTO aliases_fts(aliases_fts, rowid, alias, alias_normalized) + VALUES ('delete', old.rowid, old.alias, old.alias_normalized); + END; + + CREATE TRIGGER IF NOT EXISTS aliases_au AFTER UPDATE ON food_aliases BEGIN + INSERT INTO aliases_fts(aliases_fts, rowid, alias, alias_normalized) + VALUES ('delete', old.rowid, old.alias, old.alias_normalized); + INSERT INTO aliases_fts(rowid, alias, alias_normalized) + VALUES (new.rowid, new.alias, new.alias_normalized); + END; + ''') + + # Migration: add image_path to foods if missing + try: + cursor.execute("ALTER TABLE foods ADD COLUMN image_path TEXT") + conn.commit() + except: + pass + + conn.commit() + conn.close() + + +# ─── Food image search & download ─────────────────────────────────────────── + +def search_google_images(query: str, num: int = 6) -> list: + """Search Google Images for food photos.""" + if not GOOGLE_API_KEY or not GOOGLE_CX: + return [] + url = (f"https://www.googleapis.com/customsearch/v1" + f"?key={GOOGLE_API_KEY}&cx={GOOGLE_CX}&searchType=image" + f"&q={urllib.parse.quote(query + ' food')}&num={num}") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + return [ + {'url': item.get('link'), 'thumbnail': item.get('image', {}).get('thumbnailLink'), 'title': item.get('title')} + for item in data.get('items', []) + ] + except Exception as e: + logger.warning(f"Google image search failed: {e}") + return [] + + +def download_food_image(image_url: str, food_id: str) -> str | None: + """Download an image from URL and save to data/images/. Returns filename.""" + try: + req = urllib.request.Request(image_url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "image/*,*/*", + "Referer": "https://www.google.com/", + }) + with urllib.request.urlopen(req, timeout=15) as resp: + image_bytes = resp.read() + content_type = resp.headers.get("Content-Type", "image/jpeg") + + ext_map = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"} + ext = ext_map.get(content_type.split(";")[0], ".jpg") + # Unique filename each time so browser cache doesn't serve stale image + filename = f"{food_id}_{uuid.uuid4().hex[:8]}{ext}" + filepath = IMAGES_DIR / filename + + # Delete old image file if exists + conn = get_db() + old = conn.execute("SELECT image_path FROM foods WHERE id = ?", (food_id,)).fetchone() + if old and old['image_path']: + old_path = IMAGES_DIR / old['image_path'] + if old_path.exists(): + old_path.unlink() + + with open(filepath, "wb") as f: + f.write(image_bytes) + + # Update food record + conn.execute("UPDATE foods SET image_path = ? WHERE id = ?", (filename, food_id)) + conn.commit() + conn.close() + + return filename + except Exception as e: + logger.warning(f"Failed to download food image: {e}") + return None + + +def auto_fetch_food_image(food_id: str, food_name: str, brand: str = None): + """Auto-fetch an image for a food using Google Image Search. Runs in background.""" + import threading + + def _fetch(): + query = f"{brand} {food_name}" if brand else food_name + images = search_google_images(query, num=6) + # Try full-size URLs first (sharp images), thumbnail as last resort + for img in images: + url = img.get('url') + if url: + result = download_food_image(url, food_id) + if result: + return + # All full URLs failed — fall back to thumbnails + for img in images: + thumb = img.get('thumbnail') + if thumb: + result = download_food_image(thumb, food_id) + if result: + return + + threading.Thread(target=_fetch, daemon=True).start() + + +def seed_default_users(): + """Create default users if they don't exist.""" + conn = get_db() + cursor = conn.cursor() + + users = [ + { + "id": str(uuid.uuid4()), + "username": os.environ.get("USER1_USERNAME", "yusuf"), + "password": os.environ.get("USER1_PASSWORD", "changeme"), + "display_name": os.environ.get("USER1_DISPLAY_NAME", "Yusuf"), + "telegram_user_id": os.environ.get("USER1_TELEGRAM_ID", "5878604567"), + }, + { + "id": str(uuid.uuid4()), + "username": os.environ.get("USER2_USERNAME", "madiha"), + "password": os.environ.get("USER2_PASSWORD", "changeme"), + "display_name": os.environ.get("USER2_DISPLAY_NAME", "Madiha"), + "telegram_user_id": os.environ.get("USER2_TELEGRAM_ID", "6389024883"), + }, + ] + + for user in users: + existing = cursor.execute("SELECT id FROM users WHERE username = ?", (user["username"],)).fetchone() + if not existing: + password_hash = hashlib.sha256(user["password"].encode()).hexdigest() + cursor.execute( + "INSERT INTO users (id, username, password_hash, display_name, telegram_user_id) VALUES (?, ?, ?, ?, ?)", + (user["id"], user["username"], password_hash, user["display_name"], user["telegram_user_id"]) + ) + + conn.commit() + conn.close() + + +# ─── Auth helpers ──────────────────────────────────────────────────────────── + +def create_session(user_id: str) -> str: + """Create a new session token for a user.""" + token = secrets.token_urlsafe(32) + expires = (datetime.now() + timedelta(days=30)).isoformat() + conn = get_db() + conn.execute("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + (token, user_id, expires)) + conn.commit() + conn.close() + return token + + +def get_user_from_session(token: str) -> dict | None: + """Get user from session token.""" + if not token: + return None + conn = get_db() + row = conn.execute(''' + SELECT u.id, u.username, u.display_name, u.telegram_user_id + FROM sessions s JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ? + ''', (token, datetime.now().isoformat())).fetchone() + conn.close() + if row: + return dict(row) + return None + + +def get_user_from_api_key_and_telegram(api_key: str, telegram_user_id: str) -> dict | None: + """Authenticate via API key + Telegram user ID (for service-to-service).""" + if not API_KEY or api_key != API_KEY: + return None + conn = get_db() + row = conn.execute( + "SELECT id, username, display_name, telegram_user_id FROM users WHERE telegram_user_id = ?", + (telegram_user_id,) + ).fetchone() + conn.close() + if row: + return dict(row) + return None + + +# ─── Food search & resolve engine ──────────────────────────────────────────── + +def search_foods(query: str, user_id: str = None, limit: int = 20) -> list: + """ + Layered food search: + 1. Exact normalized match + 2. Alias exact match + 3. Tokenized/contains match + 4. FTS5 candidate retrieval + 5. Similarity scoring over top candidates + """ + conn = get_db() + norm_query = normalize_food_name(query) + query_tokens = tokenize_food_name(query) + candidates = {} # food_id -> {food_data, score, match_type} + + # Layer 1: Exact normalized match + rows = conn.execute( + "SELECT * FROM foods WHERE normalized_name = ? AND status != 'archived'", + (norm_query,) + ).fetchall() + for row in rows: + food = dict(row) + candidates[food['id']] = {'food': food, 'score': 1.0, 'match_type': 'exact'} + + # Layer 2: Alias exact match + alias_rows = conn.execute( + "SELECT fa.food_id, f.* FROM food_aliases fa JOIN foods f ON fa.food_id = f.id " + "WHERE fa.alias_normalized = ? AND f.status != 'archived'", + (norm_query,) + ).fetchall() + for row in alias_rows: + food = dict(row) + fid = food['food_id'] + if fid not in candidates: + candidates[fid] = {'food': food, 'score': 0.95, 'match_type': 'alias_exact'} + + # Layer 3: Tokenized/contains match (all query tokens appear in food name) + if query_tokens and len(candidates) < limit: + like_clauses = " AND ".join(["normalized_name LIKE ?" for _ in query_tokens]) + like_params = [f"%{t}%" for t in query_tokens] + rows = conn.execute( + f"SELECT * FROM foods WHERE {like_clauses} AND status != 'archived' LIMIT ?", + like_params + [limit] + ).fetchall() + for row in rows: + food = dict(row) + if food['id'] not in candidates: + score = similarity_score(query, food['name']) + candidates[food['id']] = {'food': food, 'score': max(score, 0.7), 'match_type': 'token_match'} + + # Layer 4: FTS5 search + if len(candidates) < limit: + # Build FTS query: each token as a prefix match + fts_terms = " OR ".join([f'"{t}"*' for t in query_tokens if t]) + if fts_terms: + try: + fts_rows = conn.execute( + "SELECT f.* FROM foods_fts fts JOIN foods f ON f.rowid = fts.rowid " + "WHERE foods_fts MATCH ? AND f.status != 'archived' LIMIT ?", + (fts_terms, limit * 2) + ).fetchall() + for row in fts_rows: + food = dict(row) + if food['id'] not in candidates: + score = similarity_score(query, food['name']) + candidates[food['id']] = {'food': food, 'score': score, 'match_type': 'fts'} + except sqlite3.OperationalError: + pass # FTS query syntax error, skip + + # Also search aliases FTS + try: + alias_fts_rows = conn.execute( + "SELECT fa.food_id, f.* FROM aliases_fts afts " + "JOIN food_aliases fa ON fa.rowid = afts.rowid " + "JOIN foods f ON fa.food_id = f.id " + "WHERE aliases_fts MATCH ? AND f.status != 'archived' LIMIT ?", + (fts_terms, limit) + ).fetchall() + for row in alias_fts_rows: + food = dict(row) + fid = food.get('food_id', food['id']) + if fid not in candidates: + score = similarity_score(query, food['name']) + candidates[fid] = {'food': food, 'score': max(score, 0.5), 'match_type': 'alias_fts'} + except sqlite3.OperationalError: + pass + + conn.close() + + # Sort by score descending and return top N + sorted_candidates = sorted(candidates.values(), key=lambda x: x['score'], reverse=True)[:limit] + results = [] + for c in sorted_candidates: + food = c['food'] + # Get servings for this food + servings = get_food_servings(food['id']) + results.append({ + 'id': food['id'], + 'name': food['name'], + 'brand': food.get('brand'), + 'base_unit': food.get('base_unit', '100g'), + 'calories_per_base': food.get('calories_per_base', 0), + 'protein_per_base': food.get('protein_per_base', 0), + 'carbs_per_base': food.get('carbs_per_base', 0), + 'fat_per_base': food.get('fat_per_base', 0), + 'status': food.get('status', 'confirmed'), + 'image_path': food.get('image_path'), + 'servings': servings, + 'score': round(c['score'], 3), + 'match_type': c['match_type'], + }) + return results + + +# ─── External nutrition lookup (OpenFoodFacts + USDA) ──────────────────────── + +def _http_get_json(url: str, timeout: int = 15) -> dict | None: + """Make a GET request and return parsed JSON, or None on failure.""" + try: + req = urllib.request.Request(url, headers={'User-Agent': 'CalorieTracker/1.0'}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode('utf-8')) + except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError) as e: + logger.debug(f"External lookup failed for {url}: {e}") + return None + + +def search_openfoodfacts(query: str, limit: int = 5) -> list: + """Search OpenFoodFacts for food products using v2 API. + Returns list of {name, brand, barcode, calories_per_100g, protein, carbs, fat, serving_size, serving_unit, source} + """ + encoded = urllib.parse.quote(query) + # v2 search API — more reliable than the old cgi endpoint + # Try category search first (best for branded products) + url = (f"https://world.openfoodfacts.org/api/v2/search?" + f"categories_tags_en={encoded}&" + f"fields=product_name,brands,code,nutriments,serving_size,serving_quantity&" + f"page_size={limit}&sort_by=unique_scans_n") + data = _http_get_json(url) + + # Fallback: v2 text search (search_terms param) + if not data or not data.get('products'): + url = (f"https://world.openfoodfacts.org/api/v2/search?" + f"search_terms={encoded}&" + f"fields=product_name,brands,code,nutriments,serving_size,serving_quantity&" + f"page_size={limit}&sort_by=unique_scans_n") + data = _http_get_json(url) + + if not data or 'products' not in data: + return [] + + results = [] + for p in data['products'][:limit]: + nuts = p.get('nutriments', {}) + cal = nuts.get('energy-kcal_100g') or 0 + # If no kcal, try kJ and convert + if not cal: + kj = nuts.get('energy_100g') or 0 + if kj: + cal = round(float(kj) / 4.184, 1) + + name = p.get('product_name', '').strip() + if not name or not cal: + continue + + results.append({ + 'name': name, + 'brand': (p.get('brands') or '').split(',')[0].strip() or None, + 'barcode': p.get('code'), + 'calories_per_100g': round(float(cal), 1), + 'protein_per_100g': round(float(nuts.get('proteins_100g', 0) or 0), 1), + 'carbs_per_100g': round(float(nuts.get('carbohydrates_100g', 0) or 0), 1), + 'fat_per_100g': round(float(nuts.get('fat_100g', 0) or 0), 1), + 'serving_size_text': p.get('serving_size'), + 'serving_grams': p.get('serving_quantity'), + 'source': 'openfoodfacts', + }) + return results + + +def lookup_openfoodfacts_barcode(barcode: str) -> dict | None: + """Look up a specific product by barcode on OpenFoodFacts.""" + url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json?fields=product_name,brands,code,nutriments,serving_size,serving_quantity" + data = _http_get_json(url) + if not data or data.get('status') != 1 or 'product' not in data: + return None + + p = data['product'] + nuts = p.get('nutriments', {}) + cal = nuts.get('energy-kcal_100g', 0) or nuts.get('energy_100g', 0) + if cal and not nuts.get('energy-kcal_100g'): + cal = round(cal / 4.184, 1) + + name = p.get('product_name', '').strip() + if not name: + return None + + return { + 'name': name, + 'brand': (p.get('brands') or '').split(',')[0].strip() or None, + 'barcode': p.get('code'), + 'calories_per_100g': round(float(cal), 1), + 'protein_per_100g': round(float(nuts.get('proteins_100g', 0) or 0), 1), + 'carbs_per_100g': round(float(nuts.get('carbohydrates_100g', 0) or 0), 1), + 'fat_per_100g': round(float(nuts.get('fat_100g', 0) or 0), 1), + 'serving_size_text': p.get('serving_size'), + 'serving_grams': p.get('serving_quantity'), + 'source': 'openfoodfacts', + } + + +def search_usda(query: str, limit: int = 5) -> list: + """Search USDA FoodData Central for foods. + Uses USDA_API_KEY env var, or falls back to DEMO_KEY (rate-limited). + Free key: https://fdc.nal.usda.gov/api-key-signup.html + """ + api_key = USDA_API_KEY or 'DEMO_KEY' + encoded = urllib.parse.quote(query) + url = f"https://api.nal.usda.gov/fdc/v1/foods/search?api_key={api_key}&query={encoded}&pageSize={limit}&dataType=Foundation,SR%20Legacy" + data = _http_get_json(url) + if not data or 'foods' not in data: + return [] + + results = [] + for food in data['foods'][:limit]: + # Build nutrient map with IDs for disambiguation + nutrient_by_id = {} + nutrient_by_name = {} + for n in food.get('foodNutrients', []): + nutrient_by_id[n.get('nutrientId')] = n.get('value', 0) + nutrient_by_name[n.get('nutrientName', '')] = n.get('value', 0) + + # Energy: prefer kcal (nutrientId=1008) over kJ (nutrientId=1062) + cal = nutrient_by_id.get(1008, 0) # Energy (kcal) + if not cal: + kj = nutrient_by_id.get(1062, 0) # Energy (kJ) + if kj: + cal = round(kj / 4.184, 1) + if not cal: + # Fallback: calculate from macros (4cal/g protein, 4cal/g carbs, 9cal/g fat) + protein = nutrient_by_name.get('Protein', 0) or 0 + carbs = nutrient_by_name.get('Carbohydrate, by difference', 0) or 0 + fat = nutrient_by_name.get('Total lipid (fat)', 0) or 0 + cal = round(protein * 4 + carbs * 4 + fat * 9, 1) + + protein = nutrient_by_name.get('Protein', 0) or 0 + carbs = nutrient_by_name.get('Carbohydrate, by difference', 0) or 0 + fat = nutrient_by_name.get('Total lipid (fat)', 0) or 0 + + name = food.get('description', '').strip() + if not name or (not cal and not protein): + continue + + # USDA names are uppercase, clean them up + name = name.title() + + results.append({ + 'name': name, + 'brand': food.get('brandName'), + 'barcode': food.get('gtinUpc'), + 'calories_per_100g': round(float(cal), 1), + 'protein_per_100g': round(float(protein), 1), + 'carbs_per_100g': round(float(carbs), 1), + 'fat_per_100g': round(float(fat), 1), + 'serving_size_text': None, + 'serving_grams': None, + 'source': 'usda', + 'usda_fdc_id': food.get('fdcId'), + }) + return results + + +def search_external(query: str, limit: int = 5) -> list: + """Search all external nutrition databases, deduplicate, and rank results.""" + all_results = [] + + # Query both sources + off_results = search_openfoodfacts(query, limit) + usda_results = search_usda(query, limit) + + all_results.extend(off_results) + all_results.extend(usda_results) + + if not all_results: + return [] + + # Score by name similarity to query and deduplicate + seen_names = set() + scored = [] + for r in all_results: + norm = normalize_food_name(r['name']) + if norm in seen_names: + continue + seen_names.add(norm) + score = similarity_score(query, r['name']) + r['relevance_score'] = round(score, 3) + scored.append(r) + + # Sort by relevance + scored.sort(key=lambda x: x['relevance_score'], reverse=True) + return scored[:limit] + + +def import_external_food(external_result: dict, user_id: str) -> dict: + """Import a food from an external nutrition database into the local DB.""" + serving_grams = external_result.get('serving_grams') + serving_text = external_result.get('serving_size_text') + + servings = [{'name': '100g', 'amount_in_base': 1.0, 'is_default': not serving_grams}] + if serving_grams and serving_text: + servings.append({ + 'name': serving_text, + 'amount_in_base': round(float(serving_grams) / 100, 2), + 'is_default': True, + }) + + food = create_food({ + 'name': external_result['name'], + 'brand': external_result.get('brand'), + 'barcode': external_result.get('barcode'), + 'calories_per_base': external_result['calories_per_100g'], + 'protein_per_base': external_result['protein_per_100g'], + 'carbs_per_base': external_result['carbs_per_100g'], + 'fat_per_base': external_result['fat_per_100g'], + 'base_unit': '100g', + 'status': 'confirmed', + 'notes': f"Imported from {external_result.get('source', 'external')}", + 'servings': servings, + }, user_id) + + return food + + +# ─── Natural language parsing ──────────────────────────────────────────────── + +def parse_food_request(raw_text: str) -> dict: + """Parse a natural language food logging request. + Extracts: food_description, meal_type, quantity, unit, modifiers, exclusions + + Examples: + 'Log a single scoop hot fudge sundae from braums' + -> food='hot fudge sundae', brand='braums', quantity=1, unit='scoop', meal_type=None + 'Log 2 mince tacos for lunch. No sour cream. Just mince, cheese and lettuce' + -> food='mince tacos', quantity=2, meal_type='lunch', modifiers='just mince, cheese and lettuce', exclusions='no sour cream' + 'Add as a snack a scoop of vanilla ice cream. Not the strawberry shake' + -> food='vanilla ice cream', quantity=1, unit='scoop', meal_type='snack', exclusions='not the strawberry shake' + """ + text = raw_text.strip() + result = { + 'food_description': text, + 'meal_type': None, + 'quantity': 1.0, + 'unit': 'serving', + 'brand': None, + 'modifiers': None, + 'exclusions': None, + 'original_text': text, + } + + # Strip command prefixes: "log", "add", "track", "record" + text = re.sub(r'^(?:log|add|track|record)\s+', '', text, flags=re.IGNORECASE).strip() + + # Extract meal type from text + meal_patterns = [ + r'\bfor\s+(breakfast|lunch|dinner|snack)\b', + r'\bas\s+(?:a\s+)?(breakfast|lunch|dinner|snack)\b', + r'\b(breakfast|lunch|dinner|snack)\s*[:\-]\s*', + r'\bto\s+(breakfast|lunch|dinner|snack)\b', + ] + for pattern in meal_patterns: + m = re.search(pattern, text, re.IGNORECASE) + if m: + result['meal_type'] = m.group(1).lower() + text = text[:m.start()] + text[m.end():] + text = text.strip().rstrip('.') + break + + # Split into sentences for better parsing + sentences = re.split(r'[.!]\s+', text) + main_sentence = sentences[0] if sentences else text + extra_sentences = sentences[1:] if len(sentences) > 1 else [] + + # Extract exclusions from extra sentences and main: "no sour cream", "not the strawberry", "make sure..." + exclusion_parts = [] + modifier_parts = [] + remaining_extras = [] + for sent in extra_sentences: + sent = sent.strip().rstrip('.') + if re.match(r'^(?:no|not|without|don\'?t|never)\b', sent, re.IGNORECASE): + exclusion_parts.append(sent) + elif re.match(r'^(?:just|only|make\s+sure|with|extra|light|heavy)\b', sent, re.IGNORECASE): + # "Make sure it is only vanilla ice cream" -> modifier + modifier_parts.append(sent) + elif re.match(r'^(\d+)\s+(?:of\s+them|small|medium|large)', sent, re.IGNORECASE): + # "3 of them", "3 small ones" -> quantity override + qty_m = re.match(r'^(\d+)', sent) + if qty_m: + result['quantity'] = float(qty_m.group(1)) + # Check for size modifier + size_m = re.search(r'\b(small|medium|large)\b', sent, re.IGNORECASE) + if size_m: + modifier_parts.append(size_m.group(0).lower()) + else: + remaining_extras.append(sent) + + # Also extract from main sentence + for pattern in [r'\.\s*(?:no|not|without|don\'?t)\s+[^.]*']: + for m in re.finditer(pattern, main_sentence, re.IGNORECASE): + exclusion_parts.append(m.group().strip().lstrip('. ')) + main_sentence = re.sub(pattern, '', main_sentence, flags=re.IGNORECASE) + + # Extract inline modifiers from main: "light sauce", "with cheese" + for pattern in [r'\b(?:just|only|with|extra|light|heavy)\s+[\w,\s]+$']: + m = re.search(pattern, main_sentence, re.IGNORECASE) + if m: + modifier_parts.append(m.group().strip()) + main_sentence = main_sentence[:m.start()].strip() + + if exclusion_parts: + result['exclusions'] = '; '.join(exclusion_parts) + if modifier_parts: + result['modifiers'] = '; '.join(modifier_parts) + + # Extract brand: "from braums", "from mcdonalds" + brand_match = re.search(r'\bfrom\s+(\w[\w\s\']*?)(?:\s*[.,]|\s*$)', main_sentence, re.IGNORECASE) + if brand_match: + result['brand'] = brand_match.group(1).strip() + main_sentence = main_sentence[:brand_match.start()] + main_sentence[brand_match.end():] + + # Clean up main sentence + main_sentence = re.sub(r'\s+', ' ', main_sentence).strip().rstrip('.,;') + + # Handle "N of them" pattern still in main sentence + of_them = re.search(r'\.?\s*(\d+)\s+of\s+them\b', main_sentence, re.IGNORECASE) + if of_them: + result['quantity'] = float(of_them.group(1)) + main_sentence = main_sentence[:of_them.start()].strip() + + # Now parse quantity from the cleaned food description + parsed_qty = _parse_quantity_from_phrase(main_sentence) + + # Only override quantity from phrase parsing if we didn't already get it from "N of them"/"N small ones" + if result['quantity'] == 1.0 or parsed_qty['quantity'] != 1.0: + if parsed_qty['quantity'] != 1.0: + result['quantity'] = parsed_qty['quantity'] + result['unit'] = parsed_qty['unit'] + result['food_description'] = parsed_qty['food_name'] + + # Strip filler: "a", "an", "some", "single" from start + result['food_description'] = re.sub(r'^(?:a|an|some)\s+', '', result['food_description'], flags=re.IGNORECASE).strip() + + # Handle "single scoop X" -> qty=1, unit=scoop, food=X + scoop_match = re.match(r'^(?:single\s+)?(?:scoop|scoops)\s+(?:of\s+)?(.+)$', result['food_description'], re.IGNORECASE) + if scoop_match: + result['unit'] = 'scoop' + result['food_description'] = scoop_match.group(1).strip() + # Also catch "scoop of X" in the food name + scoop_match2 = re.match(r'^(?:scoop|scoops)\s+(?:of\s+)?(.+)$', result['food_description'], re.IGNORECASE) + if scoop_match2: + result['unit'] = 'scoop' + result['food_description'] = scoop_match2.group(1).strip() + + # Remove "single" prefix if still there + result['food_description'] = re.sub(r'^single\s+', '', result['food_description'], flags=re.IGNORECASE).strip() + + return result + + +def _ai_split_items(phrase: str) -> list[str]: + """Use AI to split a multi-food phrase into individual items. + Returns a list of strings, each describing one food with its quantity. + Falls back to returning the original phrase as a single item if AI unavailable. + """ + if not OPENAI_API_KEY: + return [phrase] + + prompt = f"""Split this food description into individual food items. Each item should include its quantity. + +Input: "{phrase}" + +Rules: +- Return a JSON array of strings +- Each string is one food item with its quantity +- Keep quantities attached to their food: "2 eggs" stays as "2 eggs", not "eggs" +- "half an egg" → "0.5 egg" +- "one porotta" → "1 porotta" +- If it's clearly one dish (like "chicken fried rice" or "egg sandwich"), keep it as one item +- Convert words to numbers: "one" → "1", "two" → "2", "half" → "0.5" +- Return ONLY the JSON array, no other text + +Examples: +- "2 eggs and toast" → ["2 eggs", "1 toast"] +- "half egg and one porotta" → ["0.5 egg", "1 porotta"] +- "1 porotta, 1 egg, 1 slice cheese" → ["1 porotta", "1 egg", "1 slice cheese"] +- "chicken fried rice" → ["1 chicken fried rice"] +- "egg sandwich with cheese" → ["1 egg sandwich with cheese"] +- "2 rotis with dal and rice" → ["2 roti", "1 dal", "1 rice"]""" + + try: + req_body = json.dumps({ + "model": OPENAI_MODEL, + "messages": [ + {"role": "system", "content": "You split food descriptions into individual items. Return ONLY a JSON array of strings."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.1, + }).encode('utf-8') + + req = urllib.request.Request( + "https://api.openai.com/v1/chat/completions", + data=req_body, + headers={ + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {OPENAI_API_KEY}', + }, + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode('utf-8')) + + content = data['choices'][0]['message']['content'].strip() + if content.startswith('```'): + content = content.split('```')[1] + if content.startswith('json'): + content = content[4:] + items = json.loads(content) + if isinstance(items, list) and len(items) > 0: + return [str(i).strip() for i in items if str(i).strip()] + except Exception as e: + logger.warning(f"AI split failed: {e}") + + return [phrase] + + +# ─── AI nutrition estimation ───────────────────────────────────────────────── + +def _ai_estimate_nutrition(food_description: str, modifiers: str = None, + exclusions: str = None, brand: str = None, + quantity: float = 1, unit: str = 'serving') -> dict | None: + """Use OpenAI to estimate nutrition for a food item. + Returns: {food_name, calories_per_base, protein_per_base, carbs_per_base, fat_per_base, + base_unit, serving_description, estimated_grams, confidence} + """ + if not OPENAI_API_KEY: + return None + + # Build the prompt + desc_parts = [food_description] + if brand: + desc_parts.append(f"from {brand}") + if modifiers: + desc_parts.append(f"({modifiers})") + if exclusions: + desc_parts.append(f"[{exclusions}]") + full_description = ' '.join(desc_parts) + + prompt = f"""Estimate nutritional information for: {full_description} +Quantity specified: {quantity} {unit} + +Return a JSON object with these fields: +- food_name: The FULL name of what was actually eaten, including key additions/toppings. Title case. Include brand if relevant. + Examples: + - "yogurt with a sprinkle of honey" → "Yogurt With Honey" + - "mince tacos no sour cream" → "Mince Tacos (No Sour Cream)" + - "smash burger" → "Homemade Smash Burger" + - "Bellwether Farms yogurt with honey" → "Bellwether Farms Yogurt With Honey" + Do NOT strip additions that change nutrition (honey, cheese, sauce, toppings). + DO strip quantities/measurements from the name. +- display_name: Short version for reuse, without one-off modifiers. E.g. "Bellwether Farms Yogurt" (without the honey) +- calories: Total calories for the ENTIRE specified quantity ({quantity} {unit}) +- protein: Total grams of protein for the entire quantity +- carbs: Total grams of carbohydrates for the entire quantity +- fat: Total grams of fat for the entire quantity +- per_serving_calories: Calories for ONE serving/piece +- per_serving_protein: Protein for ONE serving/piece +- per_serving_carbs: Carbs for ONE serving/piece +- per_serving_fat: Fat for ONE serving/piece +- base_unit: What one unit is — "piece", "scoop", "serving", "slice", etc. +- serving_description: Human-readable serving label, e.g. "1 taco", "1 scoop", "1 small pie" +- estimated_grams: Approximate grams per serving +- confidence: "high", "medium", or "low" + +IMPORTANT: +- Account for all modifiers, additions, and exclusions in the calorie/macro estimate +- The food_name should reflect what was ACTUALLY eaten (with honey, without sour cream, etc.) +- The display_name should be the reusable base food (without one-off additions) +- If a brand is specified, use brand-specific nutrition if you know it +- Be realistic about portion sizes +- Return ONLY the JSON object, no other text""" + + try: + req_body = json.dumps({ + "model": OPENAI_MODEL, + "messages": [ + {"role": "system", "content": "You are a nutrition expert. Return accurate JSON nutrition estimates only."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.2, + }).encode('utf-8') + + req = urllib.request.Request( + "https://api.openai.com/v1/chat/completions", + data=req_body, + headers={ + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {OPENAI_API_KEY}', + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode('utf-8')) + + content = data['choices'][0]['message']['content'].strip() + # Strip markdown code blocks if present + if content.startswith('```'): + content = content.split('```')[1] + if content.startswith('json'): + content = content[4:] + result = json.loads(content) + + # Validate and normalize + confidence_map = {'high': 0.85, 'medium': 0.65, 'low': 0.4} + return { + 'food_name': str(result.get('food_name', food_description)).strip(), + 'display_name': str(result.get('display_name', result.get('food_name', food_description))).strip(), + 'calories': float(result.get('calories', 0)), + 'protein': float(result.get('protein', 0)), + 'carbs': float(result.get('carbs', 0)), + 'fat': float(result.get('fat', 0)), + 'calories_per_base': float(result.get('per_serving_calories', result.get('calories', 0))), + 'protein_per_base': float(result.get('per_serving_protein', result.get('protein', 0))), + 'carbs_per_base': float(result.get('per_serving_carbs', result.get('carbs', 0))), + 'fat_per_base': float(result.get('per_serving_fat', result.get('fat', 0))), + 'base_unit': str(result.get('base_unit', 'serving')), + 'serving_description': str(result.get('serving_description', f'1 {unit}')), + 'estimated_grams': float(result.get('estimated_grams', 0)) if result.get('estimated_grams') else None, + 'confidence': confidence_map.get(result.get('confidence', 'medium'), 0.65), + 'source': 'ai', + } + except Exception as e: + logger.warning(f"AI estimation failed: {e}") + return None + + +def _parse_quantity_from_phrase(phrase: str) -> dict: + """Extract quantity, unit, and clean food name from a raw phrase. + Examples: + '2 cups rice' -> {quantity: 2, unit: 'cup', food_name: 'rice'} + '3 chicken breasts' -> {quantity: 3, unit: 'piece', food_name: 'chicken breasts'} + 'small bowl biryani' -> {quantity: 1, unit: 'small bowl', food_name: 'biryani'} + 'chicken breast' -> {quantity: 1, unit: 'serving', food_name: 'chicken breast'} + """ + phrase = phrase.strip() + + # Household portions + household = { + 'small bowl': 'small bowl', 'medium bowl': 'medium bowl', 'large bowl': 'large bowl', + 'small plate': 'small plate', 'medium plate': 'medium plate', 'full plate': 'full plate', + 'half plate': 'half plate', 'handful': 'handful', 'bite': 'bite', + } + for pattern, unit in household.items(): + match = re.match(rf'^(?:(\d+\.?\d*)\s+)?{re.escape(pattern)}\s+(?:of\s+)?(.+)$', phrase, re.IGNORECASE) + if match: + return {'quantity': float(match.group(1) or 1), 'unit': unit, 'food_name': match.group(2).strip()} + + # Numeric + unit patterns: "2 cups rice", "4oz chicken", "100g rice" + unit_map = { + 'cups?': 'cup', 'tbsps?|tablespoons?': 'tbsp', 'tsps?|teaspoons?': 'tsp', + 'oz|ounces?': 'oz', 'g|grams?': 'g', 'pieces?': 'piece', 'slices?': 'slice', + 'servings?': 'serving', + } + for pattern, unit in unit_map.items(): + match = re.match(rf'^(\d+\.?\d*)\s*(?:{pattern})\s+(?:of\s+)?(.+)$', phrase, re.IGNORECASE) + if match: + return {'quantity': float(match.group(1)), 'unit': unit, 'food_name': match.group(2).strip()} + + # Just a number prefix: "2 chicken breasts", "3 eggs" + match = re.match(r'^(\d+\.?\d*)\s+(.+)$', phrase) + if match: + qty = float(match.group(1)) + if qty <= 50: # Reasonable food quantity + return {'quantity': qty, 'unit': 'piece', 'food_name': match.group(2).strip()} + + return {'quantity': 1, 'unit': 'serving', 'food_name': phrase} + + +# Confidence thresholds (conservative: prefer false negatives over wrong auto-matches) +THRESHOLD_AUTO_MATCH = 0.9 +THRESHOLD_CONFIRM = 0.65 + + +def resolve_food(raw_phrase: str, user_id: str, meal_type: str = None, + portion_text: str = None, entry_date: str = None, + source: str = 'api') -> dict: + """ + Smart food resolution endpoint. + Resolution chain: quick-add check → parse NL → local DB → AI estimation → queue + + Stable response shape: + { + resolution_type: matched | confirm | queued | quick_add | ai_estimated + confidence: float + matched_food: {...} | null + candidate_foods: [...] | [] + ai_estimate: {...} | null + parsed: {quantity, unit, food_description, meal_type, brand, modifiers, exclusions} + raw_text: str + queue_id: str | null + reason: str + } + """ + # Step 0: Parse natural language + parsed = parse_food_request(raw_phrase) + # Use explicitly provided meal_type if given, otherwise use parsed + effective_meal = meal_type or parsed['meal_type'] + + base_response = { + 'resolution_type': None, + 'confidence': 0.0, + 'matched_food': None, + 'candidate_foods': [], + 'ai_estimate': None, + 'parsed': { + 'quantity': parsed['quantity'], + 'unit': parsed['unit'], + 'food_description': parsed['food_description'], + 'meal_type': effective_meal, + 'brand': parsed['brand'], + 'modifiers': parsed['modifiers'], + 'exclusions': parsed['exclusions'], + }, + 'raw_text': raw_phrase, + 'queue_id': None, + 'reason': None, + } + + # Step 1: Quick-add check ("450 calories", "300cal") + quick_add_match = re.match( + r'^(?:(?:log|add|track)\s+)?(\d+\.?\d*)\s*(?:cal(?:ories)?|kcal)(?:\s+.*)?$', + raw_phrase.strip(), re.IGNORECASE + ) + if quick_add_match: + calories = float(quick_add_match.group(1)) + return {**base_response, + 'resolution_type': 'quick_add', + 'confidence': 1.0, + 'parsed': {**base_response['parsed'], 'quantity': calories, 'unit': 'kcal'}, + 'reason': 'Detected quick-add calorie pattern', + } + + # Step 2: Search local DB + food_name = parsed['food_description'] + candidates = search_foods(food_name, user_id, limit=5) + + # Step 2b: Retry with singularized name if no strong match + best_local_score = candidates[0]['score'] if candidates else 0 + if best_local_score < THRESHOLD_AUTO_MATCH: + alt_name = _naive_singularize(food_name) + if alt_name.lower() != food_name.lower(): + alt_candidates = search_foods(alt_name, user_id, limit=5) + seen_ids = {c['id'] for c in candidates} + for ac in alt_candidates: + if ac['id'] not in seen_ids: + candidates.append(ac) + seen_ids.add(ac['id']) + candidates.sort(key=lambda c: c['score'], reverse=True) + best_local_score = candidates[0]['score'] if candidates else 0 + + # Build note from modifiers/exclusions (used for all match types) + mod_note_parts = [] + if parsed['modifiers']: + mod_note_parts.append(parsed['modifiers']) + if parsed['exclusions']: + mod_note_parts.append(parsed['exclusions']) + mod_note = '; '.join(mod_note_parts) if mod_note_parts else None + + # Build snapshot name override: food name + modifiers if present + snapshot_override = None + if mod_note_parts and candidates: + base_name = candidates[0]['name'] + snapshot_override = f"{base_name} ({', '.join(mod_note_parts)})" + + if best_local_score >= THRESHOLD_AUTO_MATCH: + return {**base_response, + 'resolution_type': 'matched', + 'confidence': best_local_score, + 'matched_food': candidates[0], + 'candidate_foods': candidates[:3], + 'snapshot_name_override': snapshot_override, + 'note': mod_note, + 'reason': f'High confidence local match ({best_local_score:.2f}) via {candidates[0]["match_type"]}', + } + + # Step 3: AI estimation (if OpenAI is configured and we don't have a strong local match) + ai_estimate = None + if best_local_score < THRESHOLD_CONFIRM: + ai_estimate = _ai_estimate_nutrition( + food_description=parsed['food_description'], + modifiers=parsed['modifiers'], + exclusions=parsed['exclusions'], + brand=parsed['brand'], + quantity=parsed['quantity'], + unit=parsed['unit'], + ) + if ai_estimate: + base_response['ai_estimate'] = ai_estimate + + # Step 5: Decide resolution type based on what we found + + # Local confirm-level match exists — auto-match it (no queue) + if best_local_score >= THRESHOLD_CONFIRM: + return {**base_response, + 'resolution_type': 'matched', + 'confidence': best_local_score, + 'matched_food': candidates[0], + 'candidate_foods': candidates[:3], + 'snapshot_name_override': snapshot_override, + 'note': mod_note, + 'reason': f'Auto-matched local food ({best_local_score:.2f}) via {candidates[0]["match_type"]}', + } + + # AI estimated successfully — check for existing canonical food before creating + if ai_estimate: + ai_display_name = ai_estimate.get('display_name', ai_estimate['food_name']) + + # Pre-creation dedup: search for the AI's canonical name in local DB + dedup_candidates = search_foods(ai_display_name, user_id, limit=3) + # Also try singularized form + alt_dedup = _naive_singularize(ai_display_name) + if alt_dedup.lower() != ai_display_name.lower(): + for ac in search_foods(alt_dedup, user_id, limit=3): + if not any(c['id'] == ac['id'] for c in dedup_candidates): + dedup_candidates.append(ac) + dedup_candidates.sort(key=lambda c: c['score'], reverse=True) + + existing_match = None + for dc in dedup_candidates: + if dc['score'] >= THRESHOLD_CONFIRM: + existing_match = dc + break + + if existing_match: + # Reuse existing canonical food — no new food created + logger.info(f"Dedup: reusing existing '{existing_match['name']}' for AI input '{ai_display_name}'") + matched = { + 'id': existing_match['id'], + 'name': existing_match['name'], + 'brand': existing_match.get('brand'), + 'base_unit': existing_match.get('base_unit', 'serving'), + 'calories_per_base': existing_match.get('calories_per_base', 0), + 'protein_per_base': existing_match.get('protein_per_base', 0), + 'carbs_per_base': existing_match.get('carbs_per_base', 0), + 'fat_per_base': existing_match.get('fat_per_base', 0), + 'status': existing_match.get('status', 'confirmed'), + 'servings': existing_match.get('servings', []), + 'score': existing_match['score'], + 'match_type': 'ai_dedup_matched', + } + else: + # No existing match — create new canonical food + new_food = create_food({ + 'name': ai_display_name, + 'brand': parsed['brand'], + 'calories_per_base': ai_estimate['calories_per_base'], + 'protein_per_base': ai_estimate['protein_per_base'], + 'carbs_per_base': ai_estimate['carbs_per_base'], + 'fat_per_base': ai_estimate['fat_per_base'], + 'base_unit': ai_estimate['base_unit'], + 'status': 'ai_created', + 'notes': f"AI estimated from: {raw_phrase}", + 'servings': [{ + 'name': ai_estimate['serving_description'], + 'amount_in_base': 1.0, + 'is_default': True, + }], + }, user_id) + + matched = { + 'id': new_food['id'], + 'name': new_food['name'], + 'brand': new_food.get('brand'), + 'base_unit': new_food.get('base_unit', 'serving'), + 'calories_per_base': new_food.get('calories_per_base', 0), + 'protein_per_base': new_food.get('protein_per_base', 0), + 'carbs_per_base': new_food.get('carbs_per_base', 0), + 'fat_per_base': new_food.get('fat_per_base', 0), + 'status': 'ai_created', + 'servings': new_food.get('servings', []), + 'score': ai_estimate['confidence'], + 'match_type': 'ai_created', + } + + # Build note from modifiers/exclusions + note_parts = [] + if parsed['modifiers']: + note_parts.append(parsed['modifiers']) + if parsed['exclusions']: + note_parts.append(parsed['exclusions']) + + return {**base_response, + 'resolution_type': 'ai_estimated', + 'confidence': ai_estimate['confidence'], + 'matched_food': matched, + 'snapshot_name_override': ai_estimate['food_name'], # Full name with modifiers for the entry + 'note': '; '.join(note_parts) if note_parts else None, + 'reason': f'AI estimated nutrition for "{ai_estimate["food_name"]}" (confidence: {ai_estimate["confidence"]:.2f})', + } + + # Nothing found — return as quick_add so the entry can still be created + # The gateway/frontend will handle it as a basic calorie entry + return {**base_response, + 'resolution_type': 'quick_add', + 'confidence': 0.0, + 'candidate_foods': candidates[:3], + 'reason': 'No match found — use as quick add entry', + } + + +def _create_resolution_queue_entry(user_id, raw_text, candidates, confidence, + meal_type=None, entry_date=None, + source=None, proposed_food_id=None) -> str: + """Create an entry in the food resolution queue.""" + queue_id = str(uuid.uuid4()) + conn = get_db() + conn.execute( + """INSERT INTO food_resolution_queue + (id, user_id, raw_text, proposed_food_id, candidates_json, confidence, + meal_type, entry_date, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (queue_id, user_id, raw_text, proposed_food_id, + json.dumps([{'food_id': c['id'], 'name': c['name'], 'score': c['score']} for c in candidates]), + confidence, meal_type, entry_date, source) + ) + conn.commit() + conn.close() + return queue_id + + +# ─── Food CRUD helpers ────────────────────────────────────────────────────── + +def get_food_servings(food_id: str) -> list: + """Get all serving definitions for a food.""" + conn = get_db() + rows = conn.execute( + "SELECT * FROM food_servings WHERE food_id = ? ORDER BY is_default DESC, name", + (food_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def create_food(data: dict, user_id: str) -> dict: + """Create a new food with optional servings and aliases.""" + food_id = str(uuid.uuid4()) + name = data.get('name', '').strip() + normalized = normalize_food_name(name) + + brand = data.get('brand') + brand_norm = normalize_food_name(brand) if brand else None + + conn = get_db() + conn.execute( + """INSERT INTO foods + (id, name, normalized_name, brand, brand_normalized, barcode, notes, + calories_per_base, protein_per_base, carbs_per_base, fat_per_base, + base_unit, status, created_by_user_id, is_shared) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (food_id, name, normalized, brand, brand_norm, data.get('barcode'), + data.get('notes'), + data.get('calories_per_base', 0), data.get('protein_per_base', 0), + data.get('carbs_per_base', 0), data.get('fat_per_base', 0), + data.get('base_unit', '100g'), data.get('status', 'confirmed'), + user_id, 1 if data.get('is_shared', True) else 0) + ) + + # Create default serving if provided + servings = data.get('servings', []) + if not servings: + # Auto-create a default "1 serving" entry + servings = [{'name': f'1 {data.get("base_unit", "serving")}', 'amount_in_base': 1.0, 'is_default': True}] + + for s in servings: + conn.execute( + "INSERT INTO food_servings (id, food_id, name, amount_in_base, is_default) VALUES (?, ?, ?, ?, ?)", + (str(uuid.uuid4()), food_id, s.get('name', '1 serving'), + s.get('amount_in_base', 1.0), 1 if s.get('is_default') else 0) + ) + + # Create aliases from name tokens + _auto_create_aliases(conn, food_id, name) + + conn.commit() + conn.close() + + # Auto-fetch image in background + auto_fetch_food_image(food_id, name, data.get('brand')) + + return get_food_by_id(food_id) + + +def _auto_create_aliases(conn, food_id: str, name: str): + """Auto-create aliases from the food name (e.g., 'Grilled Chicken Breast' -> ['chicken breast', 'grilled chicken']).""" + normalized = normalize_food_name(name) + tokens = normalized.split() + + aliases_to_add = set() + aliases_to_add.add(normalized) + + # Add subsets of 2+ consecutive tokens + if len(tokens) >= 2: + for i in range(len(tokens)): + for j in range(i + 2, len(tokens) + 1): + subset = ' '.join(tokens[i:j]) + if len(subset) >= 3: + aliases_to_add.add(subset) + + for alias in aliases_to_add: + try: + conn.execute( + "INSERT OR IGNORE INTO food_aliases (id, food_id, alias, alias_normalized) VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), food_id, alias, alias) + ) + except sqlite3.IntegrityError: + pass # Alias already exists for another food + + +def get_food_by_id(food_id: str) -> dict | None: + """Get a food by ID with its servings and aliases.""" + conn = get_db() + row = conn.execute("SELECT * FROM foods WHERE id = ?", (food_id,)).fetchone() + if not row: + conn.close() + return None + food = dict(row) + food['servings'] = [dict(r) for r in conn.execute( + "SELECT * FROM food_servings WHERE food_id = ? ORDER BY is_default DESC", (food_id,) + ).fetchall()] + food['aliases'] = [dict(r) for r in conn.execute( + "SELECT * FROM food_aliases WHERE food_id = ?", (food_id,) + ).fetchall()] + conn.close() + return food + + +def _audit_log(user_id: str, action: str, entity_type: str, entity_id: str, details: dict = None): + """Write an audit log entry.""" + conn = get_db() + conn.execute( + "INSERT INTO audit_log (id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), user_id, action, entity_type, entity_id, + json.dumps(details) if details else None) + ) + conn.commit() + conn.close() + + +def merge_foods(source_id: str, target_id: str, user_id: str = None) -> dict: + """Merge source food into target: move aliases, repoint entries, archive source.""" + conn = get_db() + + source = conn.execute("SELECT * FROM foods WHERE id = ?", (source_id,)).fetchone() + target = conn.execute("SELECT * FROM foods WHERE id = ?", (target_id,)).fetchone() + if not source or not target: + conn.close() + return {'error': 'Source or target food not found'} + + # Move aliases from source to target (skip duplicates) + source_aliases = conn.execute("SELECT * FROM food_aliases WHERE food_id = ?", (source_id,)).fetchall() + for alias in source_aliases: + try: + conn.execute( + "UPDATE food_aliases SET food_id = ? WHERE id = ?", + (target_id, alias['id']) + ) + except sqlite3.IntegrityError: + conn.execute("DELETE FROM food_aliases WHERE id = ?", (alias['id'],)) + + # Add source name as alias on target + try: + conn.execute( + "INSERT OR IGNORE INTO food_aliases (id, food_id, alias, alias_normalized) VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), target_id, source['name'], normalize_food_name(source['name'])) + ) + except sqlite3.IntegrityError: + pass + + # Move food_entries references (snapshots are immutable, just update food_id reference) + conn.execute("UPDATE food_entries SET food_id = ? WHERE food_id = ?", (target_id, source_id)) + + # Move template items + conn.execute("UPDATE meal_template_items SET food_id = ? WHERE food_id = ?", (target_id, source_id)) + + # Move favorites + conn.execute("DELETE FROM user_favorites WHERE food_id = ?", (source_id,)) + + # Move servings (skip duplicates by name) + source_servings = conn.execute("SELECT * FROM food_servings WHERE food_id = ?", (source_id,)).fetchall() + for serving in source_servings: + existing = conn.execute( + "SELECT id FROM food_servings WHERE food_id = ? AND name = ?", + (target_id, serving['name']) + ).fetchone() + if not existing: + conn.execute( + "UPDATE food_servings SET food_id = ? WHERE id = ?", + (target_id, serving['id']) + ) + else: + conn.execute("DELETE FROM food_servings WHERE id = ?", (serving['id'],)) + + # Archive source + conn.execute("UPDATE foods SET status = 'archived' WHERE id = ?", (source_id,)) + + conn.commit() + conn.close() + + # Audit trail + _audit_log(user_id, 'food_merged', 'food', target_id, { + 'source_id': source_id, 'source_name': source['name'], + 'target_id': target_id, 'target_name': target['name'], + }) + + return {'success': True, 'source_id': source_id, 'target_id': target_id} + + +# ─── Entry helpers ─────────────────────────────────────────────────────────── + +def calculate_entry_nutrition(food: dict, quantity: float, serving_id: str = None) -> dict: + """Calculate nutrition for a food entry based on quantity and serving.""" + base_calories = food.get('calories_per_base', 0) + base_protein = food.get('protein_per_base', 0) + base_carbs = food.get('carbs_per_base', 0) + base_fat = food.get('fat_per_base', 0) + + # If a specific serving is selected, multiply by its base amount + multiplier = quantity + if serving_id: + conn = get_db() + serving = conn.execute("SELECT * FROM food_servings WHERE id = ?", (serving_id,)).fetchone() + conn.close() + if serving: + multiplier = quantity * serving['amount_in_base'] + + return { + 'calories': round(base_calories * multiplier, 1), + 'protein': round(base_protein * multiplier, 1), + 'carbs': round(base_carbs * multiplier, 1), + 'fat': round(base_fat * multiplier, 1), + } + + +def create_food_entry(data: dict, user_id: str) -> dict: + """Create a food log entry with immutable nutrition snapshot.""" + # Idempotency check: if key provided and already used, return existing entry + idempotency_key = data.get('idempotency_key') + if idempotency_key: + conn = get_db() + existing = conn.execute( + "SELECT * FROM food_entries WHERE idempotency_key = ?", (idempotency_key,) + ).fetchone() + conn.close() + if existing: + return dict(existing) + + entry_id = str(uuid.uuid4()) + entry_type = data.get('entry_type', 'food') + food_id = data.get('food_id') + meal_type = data.get('meal_type', 'snack').lower() + entry_date = data.get('entry_date', date.today().isoformat()) + quantity = data.get('quantity', 1.0) + unit = data.get('unit', 'serving') + serving_description = data.get('serving_description') + source = data.get('source', 'web') + entry_method = data.get('entry_method', 'manual') + image_ref = data.get('image_ref') + + snapshot_serving_label = None + snapshot_grams = data.get('snapshot_grams') + + # Quick-add: just calories, no food reference + if entry_type == 'quick_add': + snapshot_name = data.get('snapshot_food_name', 'Quick add') + snapshot_cals = data.get('snapshot_calories', 0) + snapshot_protein = data.get('snapshot_protein', 0) + snapshot_carbs = data.get('snapshot_carbs', 0) + snapshot_fat = data.get('snapshot_fat', 0) + entry_method = 'quick_add' + else: + # Get food and calculate nutrition snapshot + food = get_food_by_id(food_id) + if not food: + return {'error': 'Food not found'} + + serving_id = data.get('serving_id') + nutrition = calculate_entry_nutrition(food, quantity, serving_id) + snapshot_name = data.get('snapshot_food_name_override') or food['name'] + snapshot_cals = nutrition['calories'] + snapshot_protein = nutrition['protein'] + snapshot_carbs = nutrition['carbs'] + snapshot_fat = nutrition['fat'] + + # Resolve serving label and grams for snapshot + if serving_id: + for s in food.get('servings', []): + if s['id'] == serving_id: + snapshot_serving_label = s['name'] + if not serving_description: + serving_description = f"{quantity} x {s['name']}" + # Calculate grams if base_unit is 100g + if food.get('base_unit') == '100g': + snapshot_grams = round(quantity * s['amount_in_base'] * 100, 1) + break + elif food.get('base_unit') == '100g': + snapshot_grams = round(quantity * 100, 1) + + conn = get_db() + conn.execute( + """INSERT INTO food_entries + (id, user_id, food_id, meal_type, entry_date, entry_type, + quantity, unit, serving_description, + snapshot_food_name, snapshot_serving_label, snapshot_grams, + snapshot_calories, snapshot_protein, snapshot_carbs, snapshot_fat, + source, entry_method, raw_text, confidence_score, note, image_ref, + ai_metadata, idempotency_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (entry_id, user_id, food_id, meal_type, entry_date, entry_type, + quantity, unit, serving_description, + snapshot_name, snapshot_serving_label, snapshot_grams, + snapshot_cals, snapshot_protein, snapshot_carbs, snapshot_fat, + source, entry_method, data.get('raw_text'), data.get('confidence_score'), + data.get('note'), image_ref, + json.dumps(data.get('ai_metadata')) if data.get('ai_metadata') else None, + idempotency_key) + ) + conn.commit() + conn.close() + + return { + 'id': entry_id, + 'food_id': food_id, + 'meal_type': meal_type, + 'entry_date': entry_date, + 'entry_type': entry_type, + 'quantity': quantity, + 'unit': unit, + 'serving_description': serving_description, + 'snapshot_food_name': snapshot_name, + 'snapshot_serving_label': snapshot_serving_label, + 'snapshot_grams': snapshot_grams, + 'snapshot_calories': snapshot_cals, + 'snapshot_protein': snapshot_protein, + 'snapshot_carbs': snapshot_carbs, + 'snapshot_fat': snapshot_fat, + 'source': source, + 'entry_method': entry_method, + } + + +def get_entries_by_date(user_id: str, entry_date: str) -> list: + """Get all food entries for a user on a specific date, with food image.""" + conn = get_db() + rows = conn.execute( + """SELECT fe.*, f.image_path as food_image_path + FROM food_entries fe + LEFT JOIN foods f ON fe.food_id = f.id + WHERE fe.user_id = ? AND fe.entry_date = ? + ORDER BY + CASE fe.meal_type + WHEN 'breakfast' THEN 1 + WHEN 'lunch' THEN 2 + WHEN 'dinner' THEN 3 + WHEN 'snack' THEN 4 + ELSE 5 + END, + fe.created_at""", + (user_id, entry_date) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_daily_totals(user_id: str, entry_date: str) -> dict: + """Get daily nutrition totals.""" + conn = get_db() + row = conn.execute( + """SELECT + COALESCE(SUM(snapshot_calories), 0) as total_calories, + COALESCE(SUM(snapshot_protein), 0) as total_protein, + COALESCE(SUM(snapshot_carbs), 0) as total_carbs, + COALESCE(SUM(snapshot_fat), 0) as total_fat, + COUNT(*) as entry_count + FROM food_entries + WHERE user_id = ? AND entry_date = ?""", + (user_id, entry_date) + ).fetchone() + conn.close() + return dict(row) + + +def get_goals_for_date(user_id: str, for_date: str) -> dict | None: + """Get the goal for a user on a specific date. + Uses date ranges, not is_active flag — historical goals remain queryable.""" + conn = get_db() + row = conn.execute( + """SELECT * FROM goals + WHERE user_id = ? AND start_date <= ? + AND (end_date IS NULL OR end_date >= ?) + ORDER BY start_date DESC LIMIT 1""", + (user_id, for_date, for_date) + ).fetchone() + conn.close() + if row: + return dict(row) + return None + + +def get_recent_foods(user_id: str, limit: int = 20) -> list: + """Get recently logged foods for a user (deduplicated).""" + conn = get_db() + rows = conn.execute( + """SELECT DISTINCT fe.food_id, fe.snapshot_food_name, f.calories_per_base, + f.protein_per_base, f.carbs_per_base, f.fat_per_base, f.base_unit, + MAX(fe.created_at) as last_used + FROM food_entries fe + JOIN foods f ON fe.food_id = f.id + WHERE fe.user_id = ? AND fe.food_id IS NOT NULL AND f.status != 'archived' + GROUP BY fe.food_id + ORDER BY last_used DESC + LIMIT ?""", + (user_id, limit) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_frequent_foods(user_id: str, limit: int = 20) -> list: + """Get most frequently logged foods for a user.""" + conn = get_db() + rows = conn.execute( + """SELECT fe.food_id, fe.snapshot_food_name, f.calories_per_base, + f.protein_per_base, f.carbs_per_base, f.fat_per_base, f.base_unit, + COUNT(*) as use_count, MAX(fe.created_at) as last_used + FROM food_entries fe + JOIN foods f ON fe.food_id = f.id + WHERE fe.user_id = ? AND fe.food_id IS NOT NULL AND f.status != 'archived' + GROUP BY fe.food_id + ORDER BY use_count DESC + LIMIT ?""", + (user_id, limit) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ─── HTTP Handler ──────────────────────────────────────────────────────────── + +class CalorieHandler(BaseHTTPRequestHandler): + """HTTP request handler for the Calorie Tracker API.""" + + def _get_user(self) -> dict | None: + """Authenticate the request. Supports session cookie, Bearer token, or API key + telegram_id.""" + # Check session cookie + cookie = SimpleCookie(self.headers.get('Cookie', '')) + if 'session' in cookie: + user = get_user_from_session(cookie['session'].value) + if user: + return user + + # Check Authorization header + auth = self.headers.get('Authorization', '') + if auth.startswith('Bearer '): + token = auth[7:] + user = get_user_from_session(token) + if user: + return user + + # Check API key + telegram_user_id (service-to-service) + api_key = self.headers.get('X-API-Key', '') + telegram_id = self.headers.get('X-Telegram-User-Id', '') + if api_key and telegram_id: + user = get_user_from_api_key_and_telegram(api_key, telegram_id) + if user: + return user + + return None + + def _send_json(self, data, status=200): + """Send a JSON response.""" + body = json.dumps(data, default=str).encode('utf-8') + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', len(body)) + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-Telegram-User-Id') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS') + self.end_headers() + self.wfile.write(body) + + def _read_body(self) -> dict: + """Read and parse JSON request body.""" + length = int(self.headers.get('Content-Length', 0)) + if length == 0: + return {} + body = self.rfile.read(length) + return json.loads(body) + + def _require_auth(self) -> dict | None: + """Require authentication; send 401 if not authenticated.""" + user = self._get_user() + if not user: + self._send_json({'error': 'Unauthorized'}, 401) + return None + return user + + def do_OPTIONS(self): + """Handle CORS preflight.""" + self.send_response(204) + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-Telegram-User-Id') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS') + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip('/') + params = parse_qs(parsed.query) + + # ── Public routes ── + if path == '/api/health': + return self._send_json({'status': 'ok'}) + + # ── Serve food images ── + import mimetypes + if path.startswith('/images/'): + filename = path.split('/')[-1] + filepath = IMAGES_DIR / filename + if filepath.exists() and filepath.is_file(): + mime = mimetypes.guess_type(str(filepath))[0] or 'image/jpeg' + self.send_response(200) + self.send_header('Content-Type', mime) + self.send_header('Cache-Control', 'public, max-age=86400') + data = filepath.read_bytes() + self.send_header('Content-Length', len(data)) + self.end_headers() + self.wfile.write(data) + return + self.send_response(404) + self.end_headers() + return + + # ── Auth required routes ── + user = self._require_auth() + if not user: + return + + # GET /api/user + if path == '/api/user': + return self._send_json(user) + + # GET /api/foods/search?q=... + if path == '/api/foods/search': + query = params.get('q', [''])[0] + limit = int(params.get('limit', ['20'])[0]) + if not query: + return self._send_json([]) + results = search_foods(query, user['id'], limit) + return self._send_json(results) + + # GET /api/foods — list all foods + if path == '/api/foods': + limit = int(params.get('limit', ['100'])[0]) + conn = get_db() + rows = conn.execute( + "SELECT * FROM foods WHERE status != 'archived' ORDER BY name COLLATE NOCASE LIMIT ?", + (limit,) + ).fetchall() + conn.close() + result = [] + for r in rows: + food = dict(r) + food['servings'] = get_food_servings(food['id']) + result.append(food) + return self._send_json(result) + + # GET /api/foods/recent + if path == '/api/foods/recent': + limit = int(params.get('limit', ['20'])[0]) + return self._send_json(get_recent_foods(user['id'], limit)) + + # GET /api/foods/frequent + if path == '/api/foods/frequent': + limit = int(params.get('limit', ['20'])[0]) + return self._send_json(get_frequent_foods(user['id'], limit)) + + # GET /api/foods/external?q=... — search OpenFoodFacts + USDA + if path == '/api/foods/external': + query = params.get('q', [''])[0] + limit = int(params.get('limit', ['10'])[0]) + if not query: + return self._send_json([]) + results = search_external(query, limit) + return self._send_json(results) + + # GET /api/foods/barcode/ — lookup by barcode + barcode_match = re.match(r'^/api/foods/barcode/(\d+)$', path) + if barcode_match: + barcode = barcode_match.group(1) + # Check local DB first + conn = get_db() + local = conn.execute("SELECT * FROM foods WHERE barcode = ? AND status != 'archived'", (barcode,)).fetchone() + conn.close() + if local: + food = get_food_by_id(local['id']) + return self._send_json({'source': 'local', 'food': food}) + # Try OpenFoodFacts + result = lookup_openfoodfacts_barcode(barcode) + if result: + return self._send_json({'source': 'openfoodfacts', 'external': result}) + return self._send_json({'error': 'Barcode not found'}, 404) + + # GET /api/foods/ + food_match = re.match(r'^/api/foods/([a-f0-9-]+)$', path) + if food_match: + food = get_food_by_id(food_match.group(1)) + if food: + return self._send_json(food) + return self._send_json({'error': 'Not found'}, 404) + + # GET /api/entries?date=...&user_id=... + if path == '/api/entries': + entry_date = params.get('date', [date.today().isoformat()])[0] + target_user = params.get('user_id', [user['id']])[0] + entries = get_entries_by_date(target_user, entry_date) + return self._send_json(entries) + + # GET /api/entries/totals?date=... + if path == '/api/entries/totals': + entry_date = params.get('date', [date.today().isoformat()])[0] + target_user = params.get('user_id', [user['id']])[0] + totals = get_daily_totals(target_user, entry_date) + return self._send_json(totals) + + # GET /api/goals/for-date?date=...&user_id=... + if path == '/api/goals/for-date': + for_date = params.get('date', [date.today().isoformat()])[0] + target_user = params.get('user_id', [user['id']])[0] + goal = get_goals_for_date(target_user, for_date) + if goal: + return self._send_json(goal) + return self._send_json({'error': 'No active goal found'}, 404) + + # GET /api/goals?user_id=... + if path == '/api/goals': + target_user = params.get('user_id', [user['id']])[0] + conn = get_db() + rows = conn.execute( + "SELECT * FROM goals WHERE user_id = ? ORDER BY start_date DESC", + (target_user,) + ).fetchall() + conn.close() + return self._send_json([dict(r) for r in rows]) + + # GET /api/templates + if path == '/api/templates': + conn = get_db() + templates = conn.execute( + "SELECT * FROM meal_templates WHERE user_id = ? AND is_archived = 0 ORDER BY updated_at DESC", + (user['id'],) + ).fetchall() + result = [] + for t in templates: + t_dict = dict(t) + items = conn.execute( + "SELECT * FROM meal_template_items WHERE template_id = ?", (t['id'],) + ).fetchall() + t_dict['items'] = [dict(i) for i in items] + result.append(t_dict) + conn.close() + return self._send_json(result) + + # GET /api/favorites + if path == '/api/favorites': + conn = get_db() + rows = conn.execute( + """SELECT f.* FROM user_favorites uf + JOIN foods f ON uf.food_id = f.id + WHERE uf.user_id = ? AND f.status != 'archived' + ORDER BY uf.created_at DESC""", + (user['id'],) + ).fetchall() + conn.close() + return self._send_json([dict(r) for r in rows]) + + # GET /api/resolution-queue + if path == '/api/resolution-queue': + conn = get_db() + rows = conn.execute( + """SELECT * FROM food_resolution_queue + WHERE user_id = ? AND resolved_at IS NULL + ORDER BY created_at DESC""", + (user['id'],) + ).fetchall() + conn.close() + return self._send_json([dict(r) for r in rows]) + + # GET /api/users (list all users — for switching view) + if path == '/api/users': + conn = get_db() + rows = conn.execute("SELECT id, username, display_name FROM users").fetchall() + conn.close() + return self._send_json([dict(r) for r in rows]) + + self._send_json({'error': 'Not found'}, 404) + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip('/') + + # ── Login (no auth required) ── + if path == '/api/auth/login': + data = self._read_body() + username = data.get('username', '').strip().lower() + password = data.get('password', '') + password_hash = hashlib.sha256(password.encode()).hexdigest() + + conn = get_db() + user = conn.execute( + "SELECT * FROM users WHERE username = ? AND password_hash = ?", + (username, password_hash) + ).fetchone() + conn.close() + + if not user: + return self._send_json({'error': 'Invalid credentials'}, 401) + + token = create_session(user['id']) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Set-Cookie', f'session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000') + self.send_header('Access-Control-Allow-Origin', '*') + body = json.dumps({'token': token, 'user': { + 'id': user['id'], 'username': user['username'], 'display_name': user['display_name'] + }}).encode() + self.send_header('Content-Length', len(body)) + self.end_headers() + self.wfile.write(body) + return + + # ── Auth required routes ── + user = self._require_auth() + if not user: + return + + # POST /api/foods + if path == '/api/foods': + data = self._read_body() + if not data.get('name'): + return self._send_json({'error': 'name is required'}, 400) + food = create_food(data, user['id']) + return self._send_json(food, 201) + + # POST /api/foods/split — AI-powered multi-item splitting + if path == '/api/foods/split': + data = self._read_body() + phrase = data.get('phrase', '').strip() + if not phrase: + return self._send_json({'error': 'phrase is required'}, 400) + items = _ai_split_items(phrase) + return self._send_json({'items': items}) + + # POST /api/foods/resolve + if path == '/api/foods/resolve': + data = self._read_body() + raw_phrase = data.get('raw_phrase', '').strip() + if not raw_phrase: + return self._send_json({'error': 'raw_phrase is required'}, 400) + result = resolve_food( + raw_phrase=raw_phrase, + user_id=user['id'], + meal_type=data.get('meal_type'), + portion_text=data.get('portion_text'), + entry_date=data.get('entry_date'), + source=data.get('source', 'api'), + ) + return self._send_json(result) + + # POST /api/images/search — Google Image Search for food photos + if path == '/api/images/search': + data = self._read_body() + query = data.get('query', '') + if not query: + return self._send_json({'error': 'query required'}, 400) + images = search_google_images(query, num=int(data.get('num', 6))) + return self._send_json({'images': images}) + + # POST /api/foods//image — download and set image from URL + image_set_match = re.match(r'^/api/foods/([a-f0-9-]+)/image$', path) + if image_set_match: + food_id = image_set_match.group(1) + data = self._read_body() + image_url = data.get('url') + if not image_url: + return self._send_json({'error': 'url required'}, 400) + filename = download_food_image(image_url, food_id) + if filename: + return self._send_json({'success': True, 'image_path': filename}) + return self._send_json({'success': False, 'error': 'Failed to download image'}) + + # POST /api/foods/import — import from external nutrition database result + if path == '/api/foods/import': + data = self._read_body() + if not data.get('name') or not data.get('calories_per_100g'): + return self._send_json({'error': 'name and calories_per_100g required'}, 400) + food = import_external_food(data, user['id']) + _audit_log(user['id'], 'food_created', 'food', food['id'], { + 'source': data.get('source', 'external'), + 'name': data['name'], + 'barcode': data.get('barcode'), + }) + return self._send_json(food, 201) + + # POST /api/foods/merge + if path == '/api/foods/merge': + data = self._read_body() + source_id = data.get('source_id') + target_id = data.get('target_id') + if not source_id or not target_id: + return self._send_json({'error': 'source_id and target_id required'}, 400) + result = merge_foods(source_id, target_id, user['id']) + if 'error' in result: + return self._send_json(result, 400) + return self._send_json(result) + + # POST /api/entries + if path == '/api/entries': + data = self._read_body() + if data.get('entry_type') != 'quick_add' and not data.get('food_id'): + return self._send_json({'error': 'food_id is required (or use entry_type=quick_add)'}, 400) + entry = create_food_entry(data, user['id']) + if 'error' in entry: + return self._send_json(entry, 400) + return self._send_json(entry, 201) + + # POST /api/favorites + if path == '/api/favorites': + data = self._read_body() + food_id = data.get('food_id') + if not food_id: + return self._send_json({'error': 'food_id required'}, 400) + conn = get_db() + try: + conn.execute("INSERT INTO user_favorites (user_id, food_id) VALUES (?, ?)", + (user['id'], food_id)) + conn.commit() + except sqlite3.IntegrityError: + pass # Already a favorite + conn.close() + return self._send_json({'success': True}) + + # POST /api/templates + if path == '/api/templates': + data = self._read_body() + template_id = str(uuid.uuid4()) + conn = get_db() + conn.execute( + "INSERT INTO meal_templates (id, user_id, name, meal_type, is_favorite) VALUES (?, ?, ?, ?, ?)", + (template_id, user['id'], data.get('name', 'Untitled'), + data.get('meal_type'), 1 if data.get('is_favorite') else 0) + ) + for item in data.get('items', []): + food = get_food_by_id(item['food_id']) + if food: + nutrition = calculate_entry_nutrition(food, item.get('quantity', 1), item.get('serving_id')) + conn.execute( + """INSERT INTO meal_template_items + (id, template_id, food_id, quantity, unit, serving_description, + snapshot_food_name, snapshot_calories, snapshot_protein, snapshot_carbs, snapshot_fat) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4()), template_id, item['food_id'], + item.get('quantity', 1), item.get('unit', 'serving'), + item.get('serving_description'), + food['name'], nutrition['calories'], nutrition['protein'], + nutrition['carbs'], nutrition['fat']) + ) + conn.commit() + conn.close() + return self._send_json({'id': template_id, 'name': data.get('name')}, 201) + + # POST /api/templates//log + template_log_match = re.match(r'^/api/templates/([a-f0-9-]+)/log$', path) + if template_log_match: + template_id = template_log_match.group(1) + data = self._read_body() + meal_type = data.get('meal_type', 'snack') + entry_date = data.get('entry_date', date.today().isoformat()) + + conn = get_db() + items = conn.execute( + "SELECT * FROM meal_template_items WHERE template_id = ?", (template_id,) + ).fetchall() + conn.close() + + if not items: + return self._send_json({'error': 'Template not found or empty'}, 404) + + results = [] + for item in items: + entry = create_food_entry({ + 'food_id': item['food_id'], + 'meal_type': meal_type, + 'entry_date': entry_date, + 'quantity': item['quantity'], + 'unit': item['unit'], + 'serving_description': item['serving_description'], + 'source': 'template', + }, user['id']) + results.append(entry) + + return self._send_json({'logged': len(results), 'entries': results}) + + # POST /api/resolution-queue//resolve + resolve_match = re.match(r'^/api/resolution-queue/([a-f0-9-]+)/resolve$', path) + if resolve_match: + queue_id = resolve_match.group(1) + data = self._read_body() + action = data.get('action') # matched, created, dismissed + food_id = data.get('food_id') + + conn = get_db() + queue_item = conn.execute( + "SELECT * FROM food_resolution_queue WHERE id = ?", (queue_id,) + ).fetchone() + if not queue_item: + conn.close() + return self._send_json({'error': 'Queue item not found'}, 404) + + conn.execute( + """UPDATE food_resolution_queue + SET resolved_food_id = ?, resolved_at = ?, resolution_action = ? + WHERE id = ?""", + (food_id, datetime.now().isoformat(), action, queue_id) + ) + + result = {'success': True, 'action': action} + + # If matched or created, also create the food entry + if action in ('matched', 'created') and food_id: + entry = create_food_entry({ + 'food_id': food_id, + 'meal_type': queue_item['meal_type'] or 'snack', + 'entry_date': queue_item['entry_date'] or date.today().isoformat(), + 'quantity': queue_item['quantity'] or 1.0, + 'unit': queue_item['unit'] or 'serving', + 'source': queue_item['source'] or 'web', + 'entry_method': 'search', + 'raw_text': queue_item['raw_text'], + 'confidence_score': queue_item['confidence'], + }, user['id']) + result['entry'] = entry + + conn.commit() + conn.close() + + # Audit trail + _audit_log(user['id'], 'queue_resolved', 'queue', queue_id, { + 'action': action, 'food_id': food_id, + 'raw_text': queue_item['raw_text'], + 'confidence': queue_item['confidence'], + }) + + return self._send_json(result) + + # POST /api/foods//aliases + alias_match = re.match(r'^/api/foods/([a-f0-9-]+)/aliases$', path) + if alias_match: + food_id = alias_match.group(1) + data = self._read_body() + alias = data.get('alias', '').strip() + if not alias: + return self._send_json({'error': 'alias is required'}, 400) + alias_id = str(uuid.uuid4()) + conn = get_db() + try: + conn.execute( + "INSERT INTO food_aliases (id, food_id, alias, alias_normalized) VALUES (?, ?, ?, ?)", + (alias_id, food_id, alias, normalize_food_name(alias)) + ) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + return self._send_json({'error': 'Alias already exists'}, 409) + conn.close() + return self._send_json({'id': alias_id, 'alias': alias}, 201) + + self._send_json({'error': 'Not found'}, 404) + + def do_PATCH(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip('/') + + user = self._require_auth() + if not user: + return + + # PATCH /api/entries/ + entry_match = re.match(r'^/api/entries/([a-f0-9-]+)$', path) + if entry_match: + entry_id = entry_match.group(1) + data = self._read_body() + conn = get_db() + + entry = conn.execute("SELECT * FROM food_entries WHERE id = ? AND user_id = ?", + (entry_id, user['id'])).fetchone() + if not entry: + conn.close() + return self._send_json({'error': 'Entry not found'}, 404) + + updates = [] + params = [] + + # Allow updating these fields + for field in ['meal_type', 'entry_date', 'quantity', 'unit', 'serving_description', 'note']: + if field in data: + updates.append(f"{field} = ?") + params.append(data[field]) + + # If quantity changed and it's a food entry, recalculate snapshot + if 'quantity' in data and entry['food_id']: + food = get_food_by_id(entry['food_id']) + if food: + quantity = data['quantity'] + nutrition = calculate_entry_nutrition(food, quantity) + updates.extend([ + 'snapshot_calories = ?', 'snapshot_protein = ?', + 'snapshot_carbs = ?', 'snapshot_fat = ?' + ]) + params.extend([nutrition['calories'], nutrition['protein'], + nutrition['carbs'], nutrition['fat']]) + + if updates: + params.append(entry_id) + conn.execute(f"UPDATE food_entries SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + updated = conn.execute("SELECT * FROM food_entries WHERE id = ?", (entry_id,)).fetchone() + conn.close() + return self._send_json(dict(updated)) + + # PATCH /api/foods/ + food_match = re.match(r'^/api/foods/([a-f0-9-]+)$', path) + if food_match: + food_id = food_match.group(1) + data = self._read_body() + conn = get_db() + + updates = [] + params = [] + + for field in ['name', 'brand', 'barcode', 'notes', + 'calories_per_base', 'protein_per_base', 'carbs_per_base', 'fat_per_base', + 'base_unit', 'status', 'is_shared']: + if field in data: + if field == 'name': + updates.append('name = ?') + params.append(data['name']) + updates.append('normalized_name = ?') + params.append(normalize_food_name(data['name'])) + elif field == 'brand': + updates.append('brand = ?') + params.append(data['brand']) + updates.append('brand_normalized = ?') + params.append(normalize_food_name(data['brand']) if data['brand'] else None) + else: + updates.append(f"{field} = ?") + params.append(data[field]) + + updates.append("updated_at = ?") + params.append(datetime.now().isoformat()) + + if updates: + params.append(food_id) + conn.execute(f"UPDATE foods SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + conn.close() + return self._send_json(get_food_by_id(food_id)) + + self._send_json({'error': 'Not found'}, 404) + + def do_PUT(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip('/') + + user = self._require_auth() + if not user: + return + + # PUT /api/goals + if path == '/api/goals': + data = self._read_body() + target_user = data.get('user_id', user['id']) + start_date = data.get('start_date', date.today().isoformat()) + + conn = get_db() + # Deactivate/end previous active goals + conn.execute( + """UPDATE goals SET end_date = ?, is_active = 0 + WHERE user_id = ? AND is_active = 1 AND end_date IS NULL""", + (start_date, target_user) + ) + + goal_id = str(uuid.uuid4()) + conn.execute( + """INSERT INTO goals (id, user_id, start_date, end_date, calories, protein, carbs, fat, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)""", + (goal_id, target_user, start_date, data.get('end_date'), + data.get('calories', 2000), data.get('protein', 150), + data.get('carbs', 200), data.get('fat', 65)) + ) + conn.commit() + conn.close() + return self._send_json({'id': goal_id, 'start_date': start_date}) + + self._send_json({'error': 'Not found'}, 404) + + def do_DELETE(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip('/') + + user = self._require_auth() + if not user: + return + + # DELETE /api/entries/ + entry_match = re.match(r'^/api/entries/([a-f0-9-]+)$', path) + if entry_match: + entry_id = entry_match.group(1) + conn = get_db() + conn.execute("DELETE FROM food_entries WHERE id = ? AND user_id = ?", + (entry_id, user['id'])) + conn.commit() + conn.close() + return self._send_json({'success': True}) + + # DELETE /api/favorites/ + fav_match = re.match(r'^/api/favorites/([a-f0-9-]+)$', path) + if fav_match: + food_id = fav_match.group(1) + conn = get_db() + conn.execute("DELETE FROM user_favorites WHERE user_id = ? AND food_id = ?", + (user['id'], food_id)) + conn.commit() + conn.close() + return self._send_json({'success': True}) + + # DELETE /api/templates/ (soft delete — archive) + template_match = re.match(r'^/api/templates/([a-f0-9-]+)$', path) + if template_match: + template_id = template_match.group(1) + conn = get_db() + conn.execute("UPDATE meal_templates SET is_archived = 1 WHERE id = ? AND user_id = ?", + (template_id, user['id'])) + conn.commit() + conn.close() + return self._send_json({'success': True}) + + # DELETE /api/foods/ (soft delete — archive) + food_del_match = re.match(r'^/api/foods/([a-f0-9-]+)$', path) + if food_del_match: + food_id = food_del_match.group(1) + conn = get_db() + conn.execute("UPDATE foods SET status = 'archived', updated_at = ? WHERE id = ?", + (datetime.now().isoformat(), food_id)) + conn.commit() + conn.close() + _audit_log(user['id'], 'food_archived', 'food', food_id, {'action': 'deleted by user'}) + return self._send_json({'success': True}) + + # DELETE /api/foods//aliases/ + alias_del_match = re.match(r'^/api/foods/([a-f0-9-]+)/aliases/([a-f0-9-]+)$', path) + if alias_del_match: + alias_id = alias_del_match.group(2) + conn = get_db() + conn.execute("DELETE FROM food_aliases WHERE id = ?", (alias_id,)) + conn.commit() + conn.close() + return self._send_json({'success': True}) + + self._send_json({'error': 'Not found'}, 404) + + def log_message(self, format, *args): + """Suppress default logging for cleaner output.""" + pass + + +# ─── Main ──────────────────────────────────────────────────────────────────── + +if __name__ == '__main__': + print(f"Initializing database at {DB_PATH}...") + init_db() + seed_default_users() + print(f"Starting Calorie Tracker on port {PORT}...") + server = HTTPServer(('0.0.0.0', PORT), CalorieHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + server.server_close() diff --git a/services/inventory/Dockerfile b/services/inventory/Dockerfile new file mode 100644 index 0000000..54ccd10 --- /dev/null +++ b/services/inventory/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy application files +COPY . . + +# Expose port +EXPOSE 3000 + +# Start the application +CMD ["node", "server.js"] diff --git a/services/inventory/package.json b/services/inventory/package.json new file mode 100755 index 0000000..2f364bc --- /dev/null +++ b/services/inventory/package.json @@ -0,0 +1,19 @@ +{ + "name": "nocodb-photo-uploader", + "version": "1.0.0", + "description": "Simple photo uploader for NocoDB", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "multer": "^1.4.5-lts.1", + "axios": "^1.6.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} \ No newline at end of file diff --git a/services/inventory/server.js b/services/inventory/server.js new file mode 100755 index 0000000..9147965 --- /dev/null +++ b/services/inventory/server.js @@ -0,0 +1,2137 @@ +const express = require('express'); +const cors = require('cors'); +const multer = require('multer'); +const axios = require('axios'); +const FormData = require('form-data'); + +const app = express(); +const port = process.env.PORT || 3000; + +const config = { + ncodbUrl: process.env.NOCODB_URL || 'https://noco.quadjourney.com', + ncodbPublicUrl: process.env.NOCODB_PUBLIC_URL || process.env.NOCODB_URL || 'https://noco.quadjourney.com', + apiToken: process.env.NOCODB_API_TOKEN || '', + baseId: process.env.NOCODB_BASE_ID || 'pava9q9zccyihpt', + tableId: process.env.NOCODB_TABLE_ID || 'mash7c5nx4unukc', + columnName: process.env.NOCODB_COLUMN_NAME || 'photos', + discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL || '', + discordUsername: process.env.DISCORD_USERNAME || 'NocoDB Uploader', + discordAvatarUrl: process.env.DISCORD_AVATAR_URL || '', + publicAppUrl: process.env.PUBLIC_APP_URL || `http://localhost:${process.env.EXTERNAL_PORT || port}`, + sendToPhoneToken: process.env.SEND_TO_PHONE_TOKEN || '', + workspaceId: '' // fetched at startup +}; + +app.use(cors()); +app.use(express.json()); +// Allow form-encoded payloads from NocoDB webhook buttons +app.use(express.urlencoded({ extended: true })); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 } +}); + +function extractRowFromPayload(body = {}) { + if (!body || typeof body !== 'object') return null; + // Common NocoDB webhook shapes + if (body.data?.rows?.length) return body.data.rows[0]; + if (body.data?.records?.length) return body.data.records[0]; + if (body.data?.previous_rows?.length) return body.data.previous_rows[0]; + if (Array.isArray(body.rows) && body.rows.length) return body.rows[0]; + if (body.row) return body.row; + return null; +} + +// Optional per-request shared secret for send-to-phone endpoint +function isAuthorized(req) { + if (!config.sendToPhoneToken) return true; + const provided = + (req.query && (req.query.token || req.query.auth)) || + (req.body && (req.body.token || req.body.auth)) || + req.headers['x-send-to-phone-token']; + return provided === config.sendToPhoneToken; +} + +function buildDeepLink(rowId, itemName) { + const base = (config.publicAppUrl || '').replace(/\/$/, '') || `http://localhost:${port}`; + const params = new URLSearchParams({ rowId: String(rowId) }); + if (itemName) { + params.set('item', itemName); + } + return `${base}?${params.toString()}`; +} +const safeField = (value, fallback = 'Unknown') => (value ? String(value) : fallback); + +async function fetchRowByFilter(rowId) { + const headers = { + 'xc-token': config.apiToken, + 'Accept': 'application/json' + }; + + // Try row_id first (common UUID field), then Id as string + const queries = [ + { where: `(row_id,eq,${rowId})`, limit: 1 }, + { where: `(Id,eq,${rowId})`, limit: 1 } + ]; + + for (const params of queries) { + try { + const resp = await axios.get( + `${config.ncodbUrl}/api/v1/db/data/noco/${config.baseId}/${config.tableId}`, + { headers, params } + ); + const list = resp.data?.list || []; + if (list.length > 0) return list[0]; + } catch (err) { + // continue to next strategy + } + } + return null; +} + +async function fetchRowFlexible(rowId) { + const headers = { + 'xc-token': config.apiToken, + 'Accept': 'application/json' + }; + + // First try direct PK fetch + try { + const resp = await axios.get( + `${config.ncodbUrl}/api/v1/db/data/noco/${config.baseId}/${config.tableId}/${rowId}`, + { headers } + ); + return resp.data; + } catch (err) { + const code = err.response?.data?.error; + const status = err.response?.status; + // If PK fails (common when Noco button sends row_id UUID), try filtering + if (code === 'INVALID_PK_VALUE' || status === 404 || status === 400) { + const byFilter = await fetchRowByFilter(rowId); + if (byFilter) return byFilter; + } + throw err; + } +} + +// Search for records by item name +app.get('/search-records', async (req, res) => { + try { + if (!config.apiToken) { + return res.status(500).json({ error: 'NocoDB API token not configured' }); + } + + const searchTerm = req.query.q; + if (!searchTerm) { + return res.status(400).json({ error: 'Search term required' }); + } + + const term = searchTerm.replace(/[%_]/g, ''); // sanitize wildcards + + // Server-side search across key fields using NocoDB like filter + const where = [ + `(Item,like,%${term}%)`, + `(Order Number,like,%${term}%)`, + `(SKU,like,%${term}%)`, + `(Serial Numbers,like,%${term}%)`, + `(Name,like,%${term}%)`, + `(Tracking Number,like,%${term}%)` + ].join('~or'); + + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + { + headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' }, + params: { where, limit: 200, sort: '-Id' } + } + ); + + const rows = response.data.list || []; + + const searchResults = rows.map(row => { + let displayText = row.Item || row.Name || 'Unknown Item'; + if (row['Order Number']) displayText += ' | Order: ' + row['Order Number']; + if (row['Serial Numbers']) displayText += ' | SN: ' + row['Serial Numbers']; + if (row.SKU) displayText += ' | SKU: ' + row.SKU; + + return { + id: row.Id, + item: displayText, + received: row.Received || 'Unknown Status' + }; + }); + + res.json({ results: searchResults }); + + } catch (error) { + console.error('Search error:', error.response?.data || error.message); + res.status(500).json({ error: 'Search failed', details: error.message }); + } +}); + +// Test endpoint +app.get('/test', (req, res) => { + res.json({ message: 'Server is working!', timestamp: new Date() }); +}); + +// Get single item details +app.get('/item-details/:id', async (req, res) => { + try { + const itemId = req.params.id; + console.log('Fetching details for item ID:', itemId); + + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + itemId, + { + headers: { + 'xc-token': config.apiToken, + 'Accept': 'application/json' + } + } + ); + + const nocodbRowUrl = config.workspaceId + ? `${config.ncodbPublicUrl}/dashboard/#/${config.workspaceId}/${config.baseId}/${config.tableId}?rowId=${itemId}` + : config.ncodbPublicUrl; + res.json({ success: true, item: response.data, nocodb_url: nocodbRowUrl }); + + } catch (error) { + console.error('Error fetching item details:', error.message); + res.status(500).json({ error: 'Failed to fetch item details', details: error.message }); + } +}); + +// Send a Discord notification with a deep link for mobile upload +app.all('/send-to-phone', async (req, res) => { + try { + if (!config.discordWebhookUrl) { + return res.status(500).json({ error: 'Discord webhook not configured' }); + } + if (!config.apiToken) { + return res.status(500).json({ error: 'NocoDB API token not configured' }); + } + if (!isAuthorized(req)) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + const payloadRow = extractRowFromPayload(req.body); + let rowId = req.body?.rowId || req.body?.id || req.query.rowId || req.query.id; + if (!rowId && payloadRow) { + rowId = payloadRow.Id || payloadRow.id || payloadRow.row_id || payloadRow.rowId; + } + const primaryId = payloadRow?.Id || rowId; // Prefer numeric Id when available + console.log('Send-to-phone request payload:', { + rowId, + primaryId, + query: req.query, + body: req.body, + payloadRow + }); + if (!rowId) { + return res.status(400).json({ error: 'Missing rowId/id' }); + } + + // Fetch row to confirm and enrich the message (with fallback for row_id UUIDs) + const item = payloadRow || await fetchRowFlexible(primaryId); + const itemName = item.Item || item.Name || ''; + const orderNumber = item['Order Number'] || ''; + const sku = item.SKU || ''; + const serials = item['Serial Numbers'] || ''; + const status = item.Received || item.received || 'Unknown'; + + const deepLink = buildDeepLink(primaryId, itemName); + const contentLines = [ + `📲 Upload photos from your phone`, + `Item: ${safeField(itemName, 'Unknown')}`, + `Row ID: ${primaryId}`, + `Link: ${deepLink}` + ]; + + const embedFields = [ + { name: 'Row ID', value: String(primaryId), inline: true } + ]; + if (orderNumber) embedFields.push({ name: 'Order #', value: String(orderNumber), inline: true }); + if (sku) embedFields.push({ name: 'SKU', value: String(sku), inline: true }); + if (serials) embedFields.push({ name: 'Serials', value: String(serials).slice(0, 1000) }); + if (status) embedFields.push({ name: 'Status', value: String(status), inline: true }); + + const payload = { + content: contentLines.join('\n'), + username: config.discordUsername || undefined, + avatar_url: config.discordAvatarUrl || undefined, + embeds: [ + { + title: itemName || 'Item', + description: 'Tap the link to open the uploader on your phone.', + url: deepLink, + fields: embedFields, + footer: { text: 'NocoDB Photo Uploader' } + } + ] + }; + + await axios.post(config.discordWebhookUrl, payload); + + res.json({ + success: true, + message: 'Sent to Discord', + link: deepLink, + item: { + id: rowId, + name: itemName, + orderNumber, + sku, + serials, + status + } + }); + } catch (error) { + console.error('Send-to-phone error:', error.response?.data || error.message); + res.status(500).json({ error: 'Failed to send link', details: error.message }); + } +}); + +// Summary endpoint — server-side filtered, fast single query +app.get('/summary', async (req, res) => { + try { + if (!config.apiToken) { + return res.status(500).json({ error: 'NocoDB API token not configured' }); + } + + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + { + headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' }, + params: { + where: '(Received,eq,Issues)~or(Received,eq,Issue)~or(Received,eq,Needs Review)', + limit: 200 + } + } + ); + + const rows = response.data.list || []; + + const mapItem = (row) => ({ + id: row.Id, + item: row.Item || row.Name || 'Unknown', + orderNumber: row['Order Number'] || '', + serialNumbers: row['Serial Numbers'] || '', + sku: row.SKU || '', + received: row.Received || row.received || '', + trackingNumber: row['Tracking Number'] || '', + notes: row.Notes || '' + }); + + const issues = []; + const needsReview = []; + for (const row of rows) { + const val = (row.Received || row.received || '').toLowerCase(); + if (val === 'issues' || val === 'issue') issues.push(mapItem(row)); + else if (val === 'needs review') needsReview.push(mapItem(row)); + } + + res.json({ + issues, + issueCount: issues.length, + needsReview, + reviewCount: needsReview.length + }); + } catch (error) { + console.error('Summary fetch error:', error.message); + res.status(500).json({ error: 'Failed to fetch summary', details: error.message }); + } +}); + +// Debug endpoint to check NocoDB connection and data +app.get('/debug-nocodb', async (req, res) => { + try { + console.log('Debug: Testing NocoDB connection...'); + console.log('Config:', { + url: config.ncodbUrl, + baseId: config.baseId, + tableId: config.tableId, + hasToken: !!config.apiToken + }); + + // Fetch all records with pagination + let allRows = []; + let offset = 0; + const limit = 1000; + let hasMore = true; + + while (hasMore && offset < 10000) { + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + { + headers: { + 'xc-token': config.apiToken, + 'Accept': 'application/json' + }, + params: { + limit: limit, + offset: offset + } + } + ); + + const pageRows = response.data.list || []; + allRows = allRows.concat(pageRows); + hasMore = pageRows.length === limit; + offset += limit; + } + + res.json({ + success: true, + totalRowsRetrieved: allRows.length, + sampleRow: allRows[0] || null, + fieldNames: allRows.length > 0 ? Object.keys(allRows[0]) : [], + firstThreeRows: allRows.slice(0, 3), + message: `Successfully retrieved ${allRows.length} total records` + }); + + } catch (error) { + console.error('Debug error:', error.message); + res.status(500).json({ + error: 'Failed to connect to NocoDB', + message: error.message, + responseData: error.response?.data, + status: error.response?.status + }); + } +}); + +// Get items with issues (full details) +app.get('/issues', async (req, res) => { + try { + if (!config.apiToken) { + return res.status(500).json({ error: 'NocoDB API token not configured' }); + } + let allRows = []; + let offset = 0; + const limit = 1000; + let hasMore = true; + while (hasMore && offset < 10000) { + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + { headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' }, params: { limit, offset } } + ); + const pageRows = response.data.list || []; + allRows = allRows.concat(pageRows); + hasMore = pageRows.length === limit; + offset += limit; + } + const issues = allRows.filter(row => { + const val = (row.Received || row.received || '').toLowerCase(); + return val === 'issues' || val === 'issue'; + }).map(row => ({ + id: row.Id, + item: row.Item || row.Name || 'Unknown', + orderNumber: row['Order Number'] || '', + serialNumbers: row['Serial Numbers'] || '', + sku: row.SKU || '', + received: row.Received || row.received || '', + trackingNumber: row['Tracking Number'] || '', + notes: row.Notes || '' + })); + res.json({ issues, count: issues.length }); + } catch (error) { + console.error('Issues fetch error:', error.message); + res.status(500).json({ error: 'Failed to fetch issues', details: error.message }); + } +}); + +// Get count of rows that have issues +app.get('/needs-review-count', async (req, res) => { + try { + if (!config.apiToken) { + return res.status(500).json({ error: 'NocoDB API token not configured' }); + } + + console.log('Fetching issues count...'); + + // Fetch all records with pagination + let allRows = []; + let offset = 0; + const limit = 1000; + let hasMore = true; + + while (hasMore && offset < 10000) { + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + { + headers: { + 'xc-token': config.apiToken, + 'Accept': 'application/json' + }, + params: { + limit: limit, + offset: offset + } + } + ); + + const pageRows = response.data.list || []; + allRows = allRows.concat(pageRows); + hasMore = pageRows.length === limit; + offset += limit; + } + + console.log('API response received, total rows:', allRows.length); + const issuesCount = allRows.filter(row => { + const receivedValue = row.Received || row.received; + return receivedValue === 'Issues' || receivedValue === 'Issue'; + }).length; + + console.log('Found ' + issuesCount + ' items with issues'); + res.json({ count: issuesCount }); + + } catch (error) { + console.error('Error fetching issues count:', error.message); + res.status(500).json({ error: 'Failed to fetch count', details: error.message }); + } +}); + +app.get('/', (req, res) => { + res.send(` + + + NocoDB Photo Uploader + + + + + +
+

📷 NocoDB Photo Uploader

+ +
+

Items with Issues

+
Loading...
+
+ +
+ +
+ +
+
+
+ +
+
+ +
+ + +
+ + +
+ +
+
+ +
+

📁 Click to add photos

+

Each click adds more photos - they accumulate until you upload

+ +
+ +
+ +
+
+ + + + + +
+
+
+

Scan

+ +
+
+
Align the barcode in the frame. Scans close automatically on success.
+
+
+ + + +`); +}); + +// Proxy NocoDB images +app.get('/noco-image/*', async (req, res) => { + try { + const imagePath = req.params[0]; + if (!imagePath) return res.status(400).json({ error: 'Missing path' }); + + const imageUrl = config.ncodbUrl + '/' + imagePath; + const response = await axios.get(imageUrl, { + headers: { 'xc-token': config.apiToken }, + responseType: 'stream', + timeout: 15000 + }); + + res.set('Content-Type', response.headers['content-type'] || 'image/jpeg'); + res.set('Cache-Control', 'public, max-age=86400'); + response.data.pipe(res); + } catch (error) { + console.error('Image proxy error:', error.message); + res.status(404).json({ error: 'Image not found' }); + } +}); + +// Create new record +app.post('/create', async (req, res) => { + try { + const fields = req.body || {}; + if (!fields.Item) { + return res.status(400).json({ error: 'Item name is required' }); + } + // Only send known fields, skip junk + const allowed = ['Item', 'Order Number', 'Serial Numbers', 'SKU', 'Received', + 'Price Per Item', 'Tax', 'Total', 'QTY', 'Notes', + 'Tracking Number', 'Source', 'Platform', 'Category']; + const record = {}; + for (const key of allowed) { + if (fields[key] !== undefined && fields[key] !== null && fields[key] !== '') { + record[key] = fields[key]; + } + } + // Default status + if (!record.Received) record.Received = 'Pending'; + + const createResp = await axios.post( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + record, + { headers: { 'Content-Type': 'application/json', 'xc-token': config.apiToken } } + ); + const newId = createResp.data?.Id || createResp.data?.id; + console.log('Created new record:', newId, record.Item); + res.json({ success: true, id: newId, item: createResp.data }); + } catch (error) { + console.error('Create error:', error.response?.data || error.message); + res.status(500).json({ error: 'Create failed', details: error.message }); + } +}); + +// Update item fields +app.patch('/item/:id', async (req, res) => { + try { + const rowId = req.params.id; + const fields = req.body; + if (!rowId || !fields || Object.keys(fields).length === 0) { + return res.status(400).json({ error: 'Missing rowId or fields' }); + } + await axios.patch( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId, + fields, + { + headers: { + 'Content-Type': 'application/json', + 'xc-token': config.apiToken + } + } + ); + res.json({ success: true }); + } catch (error) { + console.error('Update error:', error.response?.data || error.message); + res.status(500).json({ error: 'Update failed', details: error.message }); + } +}); + +app.post('/duplicate/:id', async (req, res) => { + try { + const rowId = req.params.id; + // Fetch original record + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId, + { headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' } } + ); + const original = response.data; + // Strip fields that shouldn't be duplicated + const skip = ['Id', 'id', 'CreatedAt', 'UpdatedAt', 'nc_', 'photos', 'Photos', 'row_id', + 'Print', 'Print SKU', 'send to phone']; + const newRecord = {}; + for (const [key, value] of Object.entries(original)) { + if (skip.some(s => key === s || key.startsWith(s))) continue; + if (value && typeof value === 'object' && !Array.isArray(value)) continue; // skip webhook buttons etc + if (value !== null && value !== undefined) { + newRecord[key] = value; + } + } + // Create new record + const createResp = await axios.post( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + newRecord, + { headers: { 'Content-Type': 'application/json', 'xc-token': config.apiToken } } + ); + console.log('Duplicated record', rowId, '-> new ID:', createResp.data?.Id || createResp.data?.id); + res.json({ success: true, newId: createResp.data?.Id || createResp.data?.id, item: createResp.data }); + } catch (error) { + console.error('Duplicate error:', error.response?.data || error.message); + res.status(500).json({ error: 'Duplicate failed', details: error.message }); + } +}); + +app.post('/upload', upload.array('photos'), async (req, res) => { + try { + const { rowId } = req.body; + const files = req.files || []; + const rawFields = req.body.fields; + let fieldUpdates = {}; + if (rawFields) { + try { fieldUpdates = JSON.parse(rawFields); } catch (e) { fieldUpdates = {}; } + } + + if (!rowId || !config.apiToken) { + return res.status(400).json({ error: 'Missing required data' }); + } + + const hasFiles = files.length > 0; + const hasFields = fieldUpdates && Object.keys(fieldUpdates).length > 0; + if (!hasFiles && !hasFields) { + return res.status(400).json({ error: 'No files or fields to update' }); + } + + const uploadedFiles = []; + + for (let file of files) { + const formData = new FormData(); + formData.append('file', file.buffer, { + filename: file.originalname, + contentType: file.mimetype + }); + + const uploadResponse = await axios.post( + config.ncodbUrl + '/api/v1/db/storage/upload', + formData, + { + headers: { + ...formData.getHeaders(), + 'xc-token': config.apiToken + } + } + ); + + uploadedFiles.push(...uploadResponse.data); + } + + const updateData = { ...fieldUpdates }; + if (hasFiles) { + updateData[config.columnName] = uploadedFiles; + } + + await axios.patch( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId, + updateData, + { + headers: { + 'Content-Type': 'application/json', + 'xc-token': config.apiToken + } + } + ); + + res.json({ success: true, message: 'Upload successful' }); + + } catch (error) { + const status = error.response?.status; + const data = error.response?.data; + const details = typeof data === 'string' ? data.slice(0, 500) : data || error.message; + console.error('Upload error:', status || '', details); + res.status(500).json({ error: 'Upload failed', status, details }); + } +}); + +// Recent items — sorted by last updated +app.get('/recent', async (req, res) => { + try { + if (!config.apiToken) { + return res.status(500).json({ error: 'NocoDB API token not configured' }); + } + const limit = Math.min(parseInt(req.query.limit) || 30, 100); + const response = await axios.get( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId, + { + headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' }, + params: { limit, sort: '-UpdatedAt' } + } + ); + const items = (response.data.list || []).map(row => ({ + id: row.Id, + item: row.Item || row.Name || 'Unknown', + received: row.Received || '', + updatedAt: row.UpdatedAt || '' + })); + res.json({ items, count: items.length }); + } catch (error) { + console.error('Recent fetch error:', error.message); + res.status(500).json({ error: 'Failed to fetch recent items', details: error.message }); + } +}); + +// Upload from Immich — server-to-server, no browser involved +app.post('/upload-from-immich', async (req, res) => { + try { + const { rowId, assetIds, deleteAfter } = req.body; + const immichUrl = process.env.IMMICH_URL; + const immichApiKey = process.env.IMMICH_API_KEY; + if (!rowId || !assetIds?.length) { + return res.status(400).json({ error: 'Missing rowId or assetIds' }); + } + if (!immichUrl || !immichApiKey) { + return res.status(500).json({ error: 'Immich not configured on server' }); + } + + const uploaded = []; + for (const assetId of assetIds) { + try { + // Download original from Immich + const imgRes = await axios.get(`${immichUrl}/api/assets/${assetId}/original`, { + headers: { 'x-api-key': immichApiKey }, + responseType: 'arraybuffer' + }); + + // Get filename from Immich + const assetInfo = await axios.get(`${immichUrl}/api/assets/${assetId}`, { + headers: { 'x-api-key': immichApiKey } + }); + const filename = assetInfo.data.originalFileName || `immich-${assetId}.jpg`; + const mimetype = assetInfo.data.originalMimeType || 'image/jpeg'; + + // Upload to NocoDB storage + const formData = new FormData(); + formData.append('file', Buffer.from(imgRes.data), { + filename, + contentType: mimetype + }); + + const uploadResponse = await axios.post( + config.ncodbUrl + '/api/v1/db/storage/upload', + formData, + { + headers: { + ...formData.getHeaders(), + 'xc-token': config.apiToken + } + } + ); + + uploaded.push(...uploadResponse.data); + console.log(`Uploaded ${filename} from Immich to NocoDB`); + } catch (e) { + console.error(`Failed to upload asset ${assetId}:`, e.message); + } + } + + if (uploaded.length > 0) { + // Attach to row + await axios.patch( + config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId, + { [config.columnName]: uploaded }, + { + headers: { + 'Content-Type': 'application/json', + 'xc-token': config.apiToken + } + } + ); + } + + // Delete from Immich if requested + const deletedIds = []; + if (deleteAfter && uploaded.length > 0) { + try { + await axios.delete(`${immichUrl}/api/assets`, { + headers: { 'x-api-key': immichApiKey, 'Content-Type': 'application/json' }, + data: { ids: assetIds.slice(0, uploaded.length), force: false } + }); + deletedIds.push(...assetIds.slice(0, uploaded.length)); + console.log(`Trashed ${deletedIds.length} assets from Immich`); + } catch (e) { + console.error('Failed to delete from Immich:', e.message); + } + } + + res.json({ success: true, uploadedCount: uploaded.length, deletedCount: deletedIds.length }); + } catch (error) { + console.error('Immich upload error:', error.message); + res.status(500).json({ error: 'Upload failed', details: error.message }); + } +}); + +// Fetch workspace ID for NocoDB deep links +(async () => { + try { + const res = await axios.get(config.ncodbUrl + '/api/v1/db/meta/projects/', { + headers: { 'xc-token': config.apiToken } + }); + const base = (res.data.list || []).find(b => b.id === config.baseId); + if (base && base.fk_workspace_id) { + config.workspaceId = base.fk_workspace_id; + console.log(' Workspace ID: ' + config.workspaceId); + } + } catch (e) { + console.log(' Could not fetch workspace ID:', e.message); + } +})(); + +app.listen(port, () => { + const externalPort = process.env.EXTERNAL_PORT || port; + console.log('📷 NocoDB Photo Uploader running on port ' + port); + console.log('🔧 Configuration:'); + console.log(' NocoDB URL: ' + config.ncodbUrl); + console.log(' Base ID: ' + config.baseId); + console.log(' Table ID: ' + config.tableId); + console.log(' Column Name: ' + config.columnName); + console.log(' API Token: ' + (config.apiToken ? '✅ Set' : '❌ Missing')); + console.log(''); + console.log('🌐 Open http://localhost:' + externalPort + ' to use the uploader'); + console.log('🔧 Test endpoint: http://localhost:' + externalPort + '/test'); + console.log('🎨 Modern theme preview: http://localhost:' + externalPort + '/preview'); +}); + +// Preview route that enables modern theme without affecting default +app.get('/preview', (req, res) => { + res.redirect('/?theme=modern'); +}); diff --git a/services/trips/Dockerfile b/services/trips/Dockerfile new file mode 100644 index 0000000..b008a59 --- /dev/null +++ b/services/trips/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +RUN pip install --no-cache-dir PyPDF2 + +COPY . . + +CMD ["python3", "server.py"] diff --git a/services/trips/docker-compose.yml b/services/trips/docker-compose.yml new file mode 100644 index 0000000..67acf76 --- /dev/null +++ b/services/trips/docker-compose.yml @@ -0,0 +1,29 @@ +services: + trips: + build: . + container_name: trips + restart: unless-stopped + ports: + - "8087:8087" + volumes: + - ./data:/app/data + env_file: + - .env + environment: + - TZ=America/Chicago + + trips-frontend: + build: ./frontend + container_name: trips-frontend + restart: unless-stopped + ports: + - "8091:3000" + environment: + - ORIGIN=https://trips.quadjourney.com + - VITE_API_URL=http://trips:8087 + - TRIPS_API_KEY=${TRIPS_API_KEY} + - IMMICH_URL=${IMMICH_URL} + - IMMICH_API_KEY=${IMMICH_API_KEY} + - TZ=America/Chicago + depends_on: + - trips diff --git a/services/trips/email-worker/README.md b/services/trips/email-worker/README.md new file mode 100644 index 0000000..4ebf3c9 --- /dev/null +++ b/services/trips/email-worker/README.md @@ -0,0 +1,115 @@ +# Trips Email Worker + +Forward booking confirmation emails to your Trips app for automatic parsing. + +## How It Works + +1. Email arrives at `travel@quadjourney.com` +2. Cloudflare Email Routing forwards to this Worker +3. Worker extracts email content and attachments +4. Sends to Trips API for AI parsing +5. You get a Telegram notification with parsed details + +## Setup Steps + +### 1. Generate an API Key + +Generate a secure random key: +```bash +openssl rand -hex 32 +``` + +### 2. Add API Key to Trips Docker + +In your `docker-compose.yml`, add the environment variable: +```yaml +services: + trips: + environment: + - EMAIL_API_KEY=your-generated-key-here + - TELEGRAM_BOT_TOKEN=your-bot-token # Optional + - TELEGRAM_CHAT_ID=your-chat-id # Optional +``` + +Restart the container: +```bash +docker compose up -d +``` + +### 3. Create Cloudflare Worker + +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → Workers & Pages +2. Click "Create Worker" +3. Name it `trips-email-worker` +4. Paste the contents of `worker.js` +5. Click "Deploy" + +### 4. Add Worker Secrets + +In the Worker settings, add these environment variables: + +| Variable | Value | +|----------|-------| +| `TRIPS_API_URL` | `https://trips.quadjourney.com` | +| `TRIPS_API_KEY` | Your generated API key | +| `FORWARD_TO` | (Optional) Backup email address | + +Or via CLI: +```bash +cd email-worker +npm install wrangler +wrangler secret put TRIPS_API_KEY +``` + +### 5. Set Up Email Routing + +1. Go to Cloudflare Dashboard → quadjourney.com → Email → Email Routing +2. Click "Routing Rules" → "Create address" +3. Set: + - Custom address: `travel` + - Action: "Send to Worker" + - Destination: `trips-email-worker` +4. Click "Save" + +### 6. Verify DNS + +Cloudflare should auto-configure MX records. Verify: +- MX record pointing to Cloudflare's email servers +- SPF/DKIM records if sending replies + +## Testing + +Forward a booking confirmation email to `travel@quadjourney.com`. + +Check your Telegram for the parsed result, or check server logs: +```bash +docker logs trips --tail 50 +``` + +## Supported Email Types + +- Flight confirmations (airlines, booking sites) +- Hotel/lodging reservations +- Car rental confirmations +- Activity bookings + +## Attachments + +The worker can process: +- PDF attachments (itineraries, e-tickets) +- Image attachments (screenshots) +- Plain text/HTML email body + +## Troubleshooting + +**Worker not receiving emails:** +- Check Email Routing is enabled for domain +- Verify MX records are configured +- Check Worker logs in Cloudflare dashboard + +**API returns 401:** +- Verify API key matches in Worker and Docker + +**No Telegram notification:** +- Check TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set +- Verify bot has permission to message the chat diff --git a/services/trips/email-worker/worker.js b/services/trips/email-worker/worker.js new file mode 100644 index 0000000..76f9272 --- /dev/null +++ b/services/trips/email-worker/worker.js @@ -0,0 +1,325 @@ +/** + * Cloudflare Email Worker for Trips App + * Receives emails at travel@quadjourney.com and forwards to trips API for parsing + */ + +export default { + async fetch(request, env, ctx) { + return new Response('Trips Email Worker - Send emails to travel@quadjourney.com', { + headers: { 'Content-Type': 'text/plain' } + }); + }, + + async email(message, env, ctx) { + console.log(`Received email from: ${message.from}`); + console.log(`Subject: ${message.headers.get('subject')}`); + + try { + // Read the raw email + const rawEmail = await new Response(message.raw).text(); + + // Debug: log first 500 chars and search for Content-Type + console.log('Raw email preview:', rawEmail.substring(0, 500)); + + // Find where Content-Type header is + const ctIndex = rawEmail.indexOf('Content-Type:'); + console.log('Content-Type header at position:', ctIndex); + if (ctIndex > 0) { + console.log('Content-Type section:', rawEmail.substring(ctIndex, ctIndex + 200)); + } + + // Extract email parts + const subject = decodeRFC2047(message.headers.get('subject') || ''); + const from = message.from; + + // Parse email body and attachments + const { textBody, htmlBody, attachments } = parseEmail(rawEmail); + + console.log('Parsed text length:', textBody.length); + console.log('Parsed html length:', htmlBody.length); + + // Prepare the content for the trips API + const bodyContent = textBody || stripHtml(htmlBody) || '(No text content)'; + const emailContent = ` +Subject: ${subject} +From: ${from} +Date: ${new Date().toISOString()} + +${bodyContent} + `.trim(); + + console.log('Final content length:', emailContent.length); + + // Send to trips API + const result = await sendToTripsAPI(env, emailContent, attachments); + + console.log('Parse result:', JSON.stringify(result)); + + // Optionally forward the email to a backup address + if (env.FORWARD_TO) { + await message.forward(env.FORWARD_TO); + } + + } catch (error) { + console.error('Error processing email:', error); + if (env.FORWARD_TO) { + await message.forward(env.FORWARD_TO); + } + } + } +}; + +/** + * Decode RFC 2047 encoded words (=?UTF-8?Q?...?= or =?UTF-8?B?...?=) + */ +function decodeRFC2047(str) { + if (!str) return ''; + + // Match encoded words + return str.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (match, charset, encoding, text) => { + try { + if (encoding.toUpperCase() === 'B') { + // Base64 + return atob(text); + } else if (encoding.toUpperCase() === 'Q') { + // Quoted-printable + return decodeQuotedPrintable(text.replace(/_/g, ' ')); + } + } catch (e) { + console.error('Failed to decode RFC2047:', e); + } + return match; + }); +} + +/** + * Decode quoted-printable encoding + */ +function decodeQuotedPrintable(str) { + return str + .replace(/=\r?\n/g, '') // Remove soft line breaks + .replace(/=([0-9A-Fa-f]{2})/g, (match, hex) => { + return String.fromCharCode(parseInt(hex, 16)); + }); +} + +/** + * Parse email to extract body and attachments (iterative, handles nested multipart) + */ +function parseEmail(rawEmail) { + const result = { + textBody: '', + htmlBody: '', + attachments: [] + }; + + // Normalize line endings to \r\n + const normalizedEmail = rawEmail.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); + + // Collect all parts to process (use a queue instead of recursion) + const partsToProcess = [normalizedEmail]; + const processedBoundaries = new Set(); + + while (partsToProcess.length > 0) { + const content = partsToProcess.shift(); + + // Find boundary - search more of the content since headers can be long + // First try to find the Content-Type header with boundary + const boundaryMatch = content.match(/Content-Type:.*?boundary="?([^"\r\n;]+)"?/is); + + console.log('Searching for boundary, found:', boundaryMatch ? boundaryMatch[1] : 'NONE'); + + if (boundaryMatch) { + const boundary = boundaryMatch[1].trim(); + console.log('Found boundary:', boundary); + + // Skip if we've already processed this boundary + if (processedBoundaries.has(boundary)) { + continue; + } + processedBoundaries.add(boundary); + + // Split by boundary + const parts = content.split('--' + boundary); + console.log('Split into', parts.length, 'parts'); + + for (let i = 1; i < parts.length; i++) { // Skip first part (preamble) + const part = parts[i]; + if (part.trim() === '--' || part.trim() === '') continue; + + // Check if this part has its own boundary (nested multipart) + const partHeader = part.substring(0, 1000); + const nestedBoundary = partHeader.match(/boundary="?([^"\r\n;]+)"?/i); + if (nestedBoundary) { + console.log('Found nested boundary:', nestedBoundary[1]); + // Add to queue for processing + partsToProcess.push(part); + } else if (part.includes('Content-Type:')) { + // Extract content from this part + extractPartContent(part, result); + } + } + } else { + // Not multipart - try to extract content directly + const bodyStart = content.indexOf('\r\n\r\n'); + if (bodyStart !== -1) { + const headers = content.substring(0, bodyStart).toLowerCase(); + const body = content.substring(bodyStart + 4); + + if (!result.textBody && !headers.includes('content-type:')) { + // Plain text email without explicit content-type + result.textBody = body; + } + } + } + } + + // Decode quoted-printable in results + if (result.textBody) { + result.textBody = decodeQuotedPrintable(result.textBody); + } + if (result.htmlBody) { + result.htmlBody = decodeQuotedPrintable(result.htmlBody); + } + + return result; +} + +/** + * Extract content from a single MIME part + */ +function extractPartContent(part, result) { + const contentTypeMatch = part.match(/Content-Type:\s*([^\r\n;]+)/i); + const contentType = contentTypeMatch ? contentTypeMatch[1].toLowerCase().trim() : ''; + const contentDisposition = part.match(/Content-Disposition:\s*([^\r\n]+)/i)?.[1] || ''; + + console.log('Extracting part with Content-Type:', contentType); + + // Find the body (after double newline) + const bodyStart = part.indexOf('\r\n\r\n'); + if (bodyStart === -1) { + console.log('No body separator found in part'); + return; + } + + let body = part.substring(bodyStart + 4); + console.log('Body length before trim:', body.length); + + // Remove trailing boundary markers + const boundaryIndex = body.indexOf('\r\n--'); + if (boundaryIndex !== -1) { + body = body.substring(0, boundaryIndex); + } + + body = body.trim(); + if (!body) return; + + // Check encoding + const isBase64 = part.toLowerCase().includes('content-transfer-encoding: base64'); + const isQuotedPrintable = part.toLowerCase().includes('content-transfer-encoding: quoted-printable'); + + // Decode if needed + if (isBase64) { + try { + body = atob(body.replace(/\s/g, '')); + } catch (e) { + console.error('Base64 decode failed:', e); + } + } else if (isQuotedPrintable) { + body = decodeQuotedPrintable(body); + } + + // Categorize the content + if (contentType.includes('text/plain') && !contentDisposition.includes('attachment')) { + // Append to existing text (for forwarded emails with multiple text parts) + result.textBody += (result.textBody ? '\n\n' : '') + body; + } else if (contentType.includes('text/html') && !contentDisposition.includes('attachment')) { + result.htmlBody += (result.htmlBody ? '\n\n' : '') + body; + } else if (contentDisposition.includes('attachment') || + contentType.includes('application/pdf') || + contentType.includes('image/')) { + const filenameMatch = contentDisposition.match(/filename="?([^";\r\n]+)"?/i); + const filename = filenameMatch ? filenameMatch[1] : 'attachment'; + + result.attachments.push({ + filename, + contentType, + data: body.replace(/\s/g, ''), + isBase64: isBase64 + }); + } +} + +/** + * Strip HTML tags from content + */ +function stripHtml(html) { + if (!html) return ''; + return html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Send parsed content to Trips API + */ +async function sendToTripsAPI(env, textContent, attachments) { + const apiUrl = env.TRIPS_API_URL || 'https://trips.quadjourney.com'; + const apiKey = env.TRIPS_API_KEY; + + if (!apiKey) { + throw new Error('TRIPS_API_KEY not configured'); + } + + // If we have PDF/image attachments, send the first one + if (attachments.length > 0) { + const attachment = attachments[0]; + + const formData = new FormData(); + + try { + const binaryData = atob(attachment.data); + const bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + const blob = new Blob([bytes], { type: attachment.contentType }); + formData.append('file', blob, attachment.filename); + } catch (e) { + console.error('Failed to process attachment:', e); + } + + formData.append('text', textContent); + + const response = await fetch(`${apiUrl}/api/parse-email`, { + method: 'POST', + headers: { + 'X-API-Key': apiKey + }, + body: formData + }); + + return await response.json(); + } + + // Text only + const response = await fetch(`${apiUrl}/api/parse-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey + }, + body: JSON.stringify({ text: textContent }) + }); + + return await response.json(); +} diff --git a/services/trips/email-worker/wrangler.toml b/services/trips/email-worker/wrangler.toml new file mode 100644 index 0000000..2bf74ac --- /dev/null +++ b/services/trips/email-worker/wrangler.toml @@ -0,0 +1,10 @@ +name = "trips-email-worker" +main = "worker.js" +compatibility_date = "2024-01-01" + +[vars] +TRIPS_API_URL = "https://trips.quadjourney.com" +# FORWARD_TO = "yusuf@example.com" # Optional backup forwarding + +# Add your API key as a secret: +# wrangler secret put TRIPS_API_KEY diff --git a/services/trips/frontend-legacy/.dockerignore b/services/trips/frontend-legacy/.dockerignore new file mode 100644 index 0000000..ce4f9d0 --- /dev/null +++ b/services/trips/frontend-legacy/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.svelte-kit +build diff --git a/services/trips/frontend-legacy/.gitignore b/services/trips/frontend-legacy/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/services/trips/frontend-legacy/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/services/trips/frontend-legacy/.npmrc b/services/trips/frontend-legacy/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/services/trips/frontend-legacy/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/services/trips/frontend-legacy/.vscode/extensions.json b/services/trips/frontend-legacy/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/services/trips/frontend-legacy/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/services/trips/frontend-legacy/Dockerfile b/services/trips/frontend-legacy/Dockerfile new file mode 100644 index 0000000..b0e6baa --- /dev/null +++ b/services/trips/frontend-legacy/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-slim +WORKDIR /app +COPY --from=build /app/build ./build +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "build"] diff --git a/services/trips/frontend-legacy/README.md b/services/trips/frontend-legacy/README.md new file mode 100644 index 0000000..c2b8455 --- /dev/null +++ b/services/trips/frontend-legacy/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.12.8 create --template minimal --types ts --no-install frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/services/trips/frontend-legacy/package-lock.json b/services/trips/frontend-legacy/package-lock.json new file mode 100644 index 0000000..36d6a36 --- /dev/null +++ b/services/trips/frontend-legacy/package-lock.json @@ -0,0 +1,2320 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.2", + "daisyui": "^5.5.19", + "tailwindcss": "^4.2.2" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", + "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/services/trips/frontend-legacy/package.json b/services/trips/frontend-legacy/package.json new file mode 100644 index 0000000..1e8ef38 --- /dev/null +++ b/services/trips/frontend-legacy/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.2", + "daisyui": "^5.5.19", + "tailwindcss": "^4.2.2" + } +} diff --git a/services/trips/frontend-legacy/src/app.css b/services/trips/frontend-legacy/src/app.css new file mode 100644 index 0000000..0e25e20 --- /dev/null +++ b/services/trips/frontend-legacy/src/app.css @@ -0,0 +1,29 @@ +@import 'tailwindcss'; +@plugin 'daisyui' { + themes: night, dim, light; +} + +/* Custom overrides for AdventureLog-style feel */ +:root { + --header-height: 4rem; +} + +/* Smooth transitions on theme change */ +* { + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +/* Better scrollbar for dark theme */ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + background: oklch(0.2 0.02 260); +} +::-webkit-scrollbar-thumb { + background: oklch(0.4 0.02 260); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: oklch(0.5 0.02 260); +} diff --git a/services/trips/frontend-legacy/src/app.d.ts b/services/trips/frontend-legacy/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/services/trips/frontend-legacy/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/services/trips/frontend-legacy/src/app.html b/services/trips/frontend-legacy/src/app.html new file mode 100644 index 0000000..10fe304 --- /dev/null +++ b/services/trips/frontend-legacy/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Trips + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/services/trips/frontend-legacy/src/hooks.server.ts b/services/trips/frontend-legacy/src/hooks.server.ts new file mode 100644 index 0000000..02951fe --- /dev/null +++ b/services/trips/frontend-legacy/src/hooks.server.ts @@ -0,0 +1,70 @@ +import type { Handle } from '@sveltejs/kit'; + +const API_BACKEND = process.env.VITE_API_URL || 'http://localhost:8087'; +const IMMICH_URL = process.env.IMMICH_URL || ''; +const IMMICH_API_KEY = process.env.IMMICH_API_KEY || ''; + +export const handle: Handle = async ({ event, resolve }) => { + // Direct Immich thumbnail proxy (bypass Python backend for speed) + if (event.url.pathname.startsWith('/api/immich/thumb/') && IMMICH_URL && IMMICH_API_KEY) { + const assetId = event.url.pathname.split('/').pop(); + try { + const response = await fetch(`${IMMICH_URL}/api/assets/${assetId}/thumbnail`, { + headers: { 'x-api-key': IMMICH_API_KEY } + }); + return new Response(response.body, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'image/webp', + 'Cache-Control': 'public, max-age=86400' + } + }); + } catch { + return new Response('', { status: 502 }); + } + } + + // Proxy /api/* and /images/* requests to the Python backend + if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) { + const targetUrl = `${API_BACKEND}${event.url.pathname}${event.url.search}`; + + const headers = new Headers(); + // Forward relevant headers + for (const [key, value] of event.request.headers.entries()) { + if (['authorization', 'content-type', 'cookie', 'x-api-key'].includes(key.toLowerCase())) { + headers.set(key, value); + } + } + + // For image/asset requests without auth (e.g. tags), use the server-side API key + if (!headers.has('authorization') && !headers.has('cookie')) { + const apiKey = process.env.TRIPS_API_KEY; + if (apiKey) { + headers.set('Authorization', `Bearer ${apiKey}`); + } + } + + try { + const response = await fetch(targetUrl, { + method: event.request.method, + headers, + body: event.request.method !== 'GET' && event.request.method !== 'HEAD' + ? await event.request.arrayBuffer() + : undefined, + }); + + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + } catch (err) { + console.error('Proxy error:', err); + return new Response(JSON.stringify({ error: 'Backend unavailable' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + return resolve(event); +}; diff --git a/services/trips/frontend-legacy/src/lib/api/client.ts b/services/trips/frontend-legacy/src/lib/api/client.ts new file mode 100644 index 0000000..a669f4c --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/api/client.ts @@ -0,0 +1,53 @@ +const API_BASE = import.meta.env.VITE_API_URL || ''; + +export function hasToken(): boolean { + return typeof window !== 'undefined' && !!localStorage.getItem('api_token'); +} + +export async function api(path: string, options: RequestInit = {}): Promise { + const token = typeof window !== 'undefined' ? localStorage.getItem('api_token') : null; + + if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.href = '/login'; + throw new Error('No token'); + } + + const headers: Record = { + ...(options.headers as Record || {}) + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (options.body && typeof options.body === 'string') { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: 'include' + }); + + if (res.status === 401) { + if (typeof window !== 'undefined') { + localStorage.removeItem('api_token'); + window.location.href = '/login'; + } + throw new Error('Unauthorized'); + } + + return res.json(); +} + +export function get(path: string) { + return api(path); +} + +export function post(path: string, data: unknown) { + return api(path, { + method: 'POST', + body: JSON.stringify(data) + }); +} diff --git a/services/trips/frontend-legacy/src/lib/api/types.ts b/services/trips/frontend-legacy/src/lib/api/types.ts new file mode 100644 index 0000000..4d5165e --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/api/types.ts @@ -0,0 +1,109 @@ +export interface Trip { + id: string; + name: string; + description: string; + start_date: string; + end_date: string; + image_path: string | null; + cover_image: string | null; + share_token: string | null; + created_at: string; + immich_album_id: string | null; +} + +export interface Transportation { + id: string; + trip_id: string; + name: string; + type: string; + flight_number: string; + from_location: string; + to_location: string; + date: string; + end_date: string; + timezone: string; + description: string; + link: string; + cost_points: number; + cost_cash: number; + from_place_id: string; + to_place_id: string; + from_lat: number | null; + from_lng: number | null; + to_lat: number | null; + to_lng: number | null; +} + +export interface Lodging { + id: string; + trip_id: string; + name: string; + type: string; + location: string; + check_in: string; + check_out: string; + timezone: string; + reservation_number: string; + description: string; + link: string; + cost_points: number; + cost_cash: number; + place_id: string; + latitude: number | null; + longitude: number | null; +} + +export interface Location { + id: string; + trip_id: string; + name: string; + description: string; + latitude: number | null; + longitude: number | null; + category: string; + visit_date: string; + start_time: string; + end_time: string; + link: string; + cost_points: number; + cost_cash: number; + hike_distance: string; + hike_difficulty: string; + hike_time: string; + place_id: string; + address: string; +} + +export interface Note { + id: string; + trip_id: string; + name: string; + content: string; + date: string; +} + +export interface TripDetail extends Trip { + transportations: Transportation[]; + lodging: Lodging[]; + locations: Location[]; + notes: Note[]; + images: TripImage[]; + documents: TripDocument[]; +} + +export interface TripImage { + id: string; + entity_type: string; + entity_id: string; + file_path: string; + is_primary: number; +} + +export interface TripDocument { + id: string; + entity_type: string; + entity_id: string; + file_path: string; + original_name: string; + mime_type: string; +} diff --git a/services/trips/frontend-legacy/src/lib/assets/favicon.svg b/services/trips/frontend-legacy/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/services/trips/frontend-legacy/src/lib/components/AIGuideModal.svelte b/services/trips/frontend-legacy/src/lib/components/AIGuideModal.svelte new file mode 100644 index 0000000..e8e2095 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/AIGuideModal.svelte @@ -0,0 +1,114 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/DocumentUpload.svelte b/services/trips/frontend-legacy/src/lib/components/DocumentUpload.svelte new file mode 100644 index 0000000..10df24a --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/DocumentUpload.svelte @@ -0,0 +1,112 @@ + + +
+ + {#if documents.length > 0} +
+ {#each documents as doc} +
+ + + + + {doc.file_name || doc.original_name || 'Document'} + + +
+ {/each} +
+ {/if} + + + {#if entityId} + + {:else} +

Save first to add documents

+ {/if} +
diff --git a/services/trips/frontend-legacy/src/lib/components/ImageUpload.svelte b/services/trips/frontend-legacy/src/lib/components/ImageUpload.svelte new file mode 100644 index 0000000..4b5bc28 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/ImageUpload.svelte @@ -0,0 +1,292 @@ + + +
+ + {#if images.length > 0} +
+ {#each images as img} +
+ + +
+ {/each} +
+ {/if} + + + {#if documents.length > 0} +
+ {#each documents as doc} +
+ + + + + {doc.file_name || doc.original_name || 'Document'} + + +
+ {/each} +
+ {/if} + + + {#if entityId} +
+ + + + + + + +
+ {:else} +

Save first to add photos

+ {/if} + + + {#if showSearch} +
+
+ { if (e.key === 'Enter') { e.preventDefault(); searchImages(); } }} + /> + + +
+ + {#if searchResults.length > 0} +
+ {#each searchResults as result} + + {/each} +
+ {:else if !searching && searchQuery} +

No results

+ {/if} +
+ {/if} + + + {#if showImmich && entityId} + { showImmich = false; onUpload(); }} onClose={() => showImmich = false} /> + {/if} +
diff --git a/services/trips/frontend-legacy/src/lib/components/ImmichPicker.svelte b/services/trips/frontend-legacy/src/lib/components/ImmichPicker.svelte new file mode 100644 index 0000000..7452d03 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/ImmichPicker.svelte @@ -0,0 +1,192 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/LocationModal.svelte b/services/trips/frontend-legacy/src/lib/components/LocationModal.svelte new file mode 100644 index 0000000..414546d --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/LocationModal.svelte @@ -0,0 +1,260 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/LodgingModal.svelte b/services/trips/frontend-legacy/src/lib/components/LodgingModal.svelte new file mode 100644 index 0000000..6955772 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/LodgingModal.svelte @@ -0,0 +1,199 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/MapsButton.svelte b/services/trips/frontend-legacy/src/lib/components/MapsButton.svelte new file mode 100644 index 0000000..466982c --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/MapsButton.svelte @@ -0,0 +1,80 @@ + + +
+ + + {#if showPicker} +
+ + +
+ {/if} +
+ +{#if showPicker} +
{ e.stopPropagation(); showPicker = false; }}>
+{/if} diff --git a/services/trips/frontend-legacy/src/lib/components/Navbar.svelte b/services/trips/frontend-legacy/src/lib/components/Navbar.svelte new file mode 100644 index 0000000..0636278 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/Navbar.svelte @@ -0,0 +1,61 @@ + + + diff --git a/services/trips/frontend-legacy/src/lib/components/NoteModal.svelte b/services/trips/frontend-legacy/src/lib/components/NoteModal.svelte new file mode 100644 index 0000000..fa23301 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/NoteModal.svelte @@ -0,0 +1,127 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/ParseModal.svelte b/services/trips/frontend-legacy/src/lib/components/ParseModal.svelte new file mode 100644 index 0000000..2900e4d --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/ParseModal.svelte @@ -0,0 +1,272 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/PlacesAutocomplete.svelte b/services/trips/frontend-legacy/src/lib/components/PlacesAutocomplete.svelte new file mode 100644 index 0000000..0a103ea --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/PlacesAutocomplete.svelte @@ -0,0 +1,106 @@ + + +
+ { if (predictions.length > 0) showDropdown = true; }} + onblur={() => setTimeout(() => showDropdown = false, 200)} + /> + {#if showDropdown} +
+ {#each predictions as pred} + + {/each} +
+ {/if} +
diff --git a/services/trips/frontend-legacy/src/lib/components/StatsBar.svelte b/services/trips/frontend-legacy/src/lib/components/StatsBar.svelte new file mode 100644 index 0000000..08fe19b --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/StatsBar.svelte @@ -0,0 +1,306 @@ + + +{#if stats} +
+ +
tripsModal?.showModal()} + > +
+
+ + + +
+
+
{formatNumber(stats.total_trips)}
+
Trips
+
+
+
+ + +
citiesModal?.showModal()} + > +
+
+ + + +
+
+
{formatNumber(stats.cities_visited)}
+
Cities Visited
+
+
+
+ + +
countriesModal?.showModal()} + > +
+
+ + + +
+
+
{formatNumber(stats.countries_visited)}
+
Countries
+
+
+
+ + +
pointsModal?.showModal()} + > +
+
+ + + +
+
+
{formatNumber(stats.total_points_redeemed)}
+
Miles Redeemed
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +{/if} diff --git a/services/trips/frontend-legacy/src/lib/components/TransportModal.svelte b/services/trips/frontend-legacy/src/lib/components/TransportModal.svelte new file mode 100644 index 0000000..ec620f9 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/TransportModal.svelte @@ -0,0 +1,240 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/TripCard.svelte b/services/trips/frontend-legacy/src/lib/components/TripCard.svelte new file mode 100644 index 0000000..4363fc3 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/TripCard.svelte @@ -0,0 +1,92 @@ + + + +
+ +
+ {#if imageUrl} + {trip.name} + {:else} +
+ + + + + +
+ {/if} + +
+ {status.label} +
+
+ + +
+

+ {trip.name} +

+ {#if trip.start_date} +
+ + + + + + + + {formatDate(trip.start_date)} — {formatDate(trip.end_date)} + +
+ {#if duration} +
{duration}
+ {/if} + {/if} + {#if trip.description} +

{trip.description}

+ {/if} +
+
+
diff --git a/services/trips/frontend-legacy/src/lib/components/TripEditModal.svelte b/services/trips/frontend-legacy/src/lib/components/TripEditModal.svelte new file mode 100644 index 0000000..6b8c4ef --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/TripEditModal.svelte @@ -0,0 +1,185 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/components/TripMap.svelte b/services/trips/frontend-legacy/src/lib/components/TripMap.svelte new file mode 100644 index 0000000..777e733 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/components/TripMap.svelte @@ -0,0 +1,116 @@ + + + + + + diff --git a/services/trips/frontend-legacy/src/lib/index.ts b/services/trips/frontend-legacy/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/services/trips/frontend-legacy/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/services/trips/frontend-legacy/src/routes/+layout.svelte b/services/trips/frontend-legacy/src/routes/+layout.svelte new file mode 100644 index 0000000..f67bb8f --- /dev/null +++ b/services/trips/frontend-legacy/src/routes/+layout.svelte @@ -0,0 +1,17 @@ + + + + Trips + + +
+ +
+ {@render children()} +
+
diff --git a/services/trips/frontend-legacy/src/routes/+page.svelte b/services/trips/frontend-legacy/src/routes/+page.svelte new file mode 100644 index 0000000..3d0e952 --- /dev/null +++ b/services/trips/frontend-legacy/src/routes/+page.svelte @@ -0,0 +1,627 @@ + + +
+ +
+
+

My Trips

+

Plan and track your adventures

+
+ +
+ + + + {#if nextTrip} + +
+ + + +
+
+
Next Adventure
+
{nextTrip.name}
+
{formatDateShort(nextTrip.start_date)} — {formatDateShort(nextTrip.end_date)}
+
+
+
{daysUntilNext()}
+
days
+
+
+ {:else if activeTrips.length > 0} + +
+ + + + +
+
+ {activeTrips[0].name} + · + Happening now! +
+
+ {/if} + + + + + {#if trips.length > 0} +
+
+ + + + { if (searchResults.length > 0) showSearchResults = true; }} + onblur={() => setTimeout(() => showSearchResults = false, 200)} + /> + {#if searchQuery} + + {/if} + + + {#if showSearchResults && searchResults.length > 0} +
+ {#each searchResults as result} + + {/each} +
+ {:else if showSearchResults && searchQuery.length >= 2 && searchResults.length === 0} +
+ No results for "{searchQuery}" +
+ {/if} +
+
+ {/if} + + {#if loading} +
+ +
+ {:else if error} +
+ + + + {error} +
+ {:else} + + {#if activeTrips.length > 0} +
+
+ +

Active Now

+
+
+ {#each activeTrips as trip (trip.id)} + + {/each} +
+
+ {/if} + + + {#if upcomingTrips.length > 0} +
+
+ +

Upcoming

+
+
+ {#each upcomingTrips as trip (trip.id)} + + {/each} +
+
+ {/if} + + + {#if pastTrips.length > 0} +
+
+ +

Past Adventures

+
+
+ {#each pastTrips as trip (trip.id)} + + {/each} +
+
+ {/if} + + + {#if trips.length === 0} +
+ + + + + +

No trips yet

+

Start planning your first adventure

+ +
+ {/if} + {/if} +
+ + + + + + diff --git a/services/trips/frontend-legacy/src/routes/login/+page.svelte b/services/trips/frontend-legacy/src/routes/login/+page.svelte new file mode 100644 index 0000000..b868da9 --- /dev/null +++ b/services/trips/frontend-legacy/src/routes/login/+page.svelte @@ -0,0 +1,82 @@ + + +
+
+
+
+ + + + + +

Trips

+

Enter your API token to continue

+
+ + {#if error} +
+ {error} +
+ {/if} + +
{ e.preventDefault(); handleLogin(); }}> +
+ + +
+ +
+
+
+
diff --git a/services/trips/frontend-legacy/src/routes/settings/+page.svelte b/services/trips/frontend-legacy/src/routes/settings/+page.svelte new file mode 100644 index 0000000..7f845fb --- /dev/null +++ b/services/trips/frontend-legacy/src/routes/settings/+page.svelte @@ -0,0 +1,24 @@ + + +
+

Settings

+ +
+
+

Session

+

Clear your API token and return to login.

+
+ +
+
+
+
diff --git a/services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte b/services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte new file mode 100644 index 0000000..1f15d4e --- /dev/null +++ b/services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte @@ -0,0 +1,751 @@ + + +{#if loading} +
+ +
+{:else if error || !trip} +
+
{error || 'Trip not found'}
+
+{:else} + + {#if heroImages.length > 0} +
+ {#each heroImages as img, i} +
+ +
+ {/each} + +
+ +
+

{trip.name}

+
+ {#if trip.start_date} + + + + + {formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)} + + {/if} + {#if (trip.locations?.length || 0) > 0} + + + + + {trip.locations.length} Locations + + {/if} +
+
+ +
+ + + + + Back + + +
+ + {#if heroImages.length > 1} + + + +
+ {currentSlide + 1} / {heroImages.length} +
+ {/if} +
+ {/if} + +
+ + {#if heroImages.length === 0} +
+
+ + + + + Back + +

{trip.name}

+ {#if trip.start_date} +

{formatDateShort(trip.start_date)} — {formatDateShort(trip.end_date)}

+ {/if} + {#if trip.description} +

{trip.description}

+ {/if} +
+
+ {/if} + + +
+
+
{trip.transportations?.length || 0}
+
Transport
+
+
+
+
{trip.lodging?.length || 0}
+
Lodging
+
+
+
+
{trip.locations?.length || 0}
+
Activities
+
+ {#if totalPoints > 0} +
+
+
{totalPoints.toLocaleString()}
+
Points
+
+ {/if} + {#if totalCash > 0} +
+
+
${totalCash.toLocaleString()}
+
Cost
+
+ {/if} +
+ + +
+
+ + +
+
+ + +
+
+
+ {#each days as day} +
+ + + + + {#if expandedDays.has(day.date)} +
+ {#each day.items as item} + {#if item.type === 'transportation'} + {@const t = item.data} +
openTransportEdit(t)}> + {#if t.images?.length > 0} +
+ {/if} +
+
+
+ + {#if t.type === 'plane'} + {:else if t.type === 'train'} + {:else}{/if} + +
+
+
{t.name || t.flight_number || `${t.from_location} → ${t.to_location}`}
+
{t.from_location} → {t.to_location}{#if t.flight_number} · {t.flight_number}{/if}
+
+
+ {#if t.date}
{formatTime(t.date)}
{/if} + {#if t.cost_points}
{t.cost_points.toLocaleString()} pts
{/if} + {#if t.cost_cash}
${t.cost_cash}
{/if} +
+
+ {#if t.to_lat || t.to_place_id || t.to_location} +
+ +
+ {/if} +
+
+ + {:else if item.type === 'location'} + {@const loc = item.data} +
openLocationEdit(loc)}> + {#if loc.images?.length > 0} +
+ {/if} +
+
+
+ + + +
+
+
+ {loc.name} + {#if loc.category}{loc.category}{/if} +
+ {#if loc.address}
{loc.address}
{/if} +
+
+ {#if loc.start_time} +
{formatTime(loc.start_time)}
+ {#if loc.end_time}
— {formatTime(loc.end_time)}
{/if} + {/if} + {#if loc.cost_points}
{loc.cost_points.toLocaleString()} pts
{/if} +
+
+ {#if loc.description} +
{@html loc.description}
+ {/if} + {#if loc.latitude || loc.place_id || loc.address} +
+ +
+ {/if} +
+
+ + {:else if item.type === 'note'} + {@const n = item.data} +
openNoteEdit(n)}> +
+
+ + + + {n.name} +
+ {#if n.content} +
{@html n.content}
+ {/if} +
+
+ {/if} + {/each} +
+ {/if} + + + {#if overnightByDate.has(day.date)} + {@const hotel = overnightByDate.get(day.date)} +
+
+ + + + Staying overnight +
+
openLodgingEdit(hotel)}> + + + +
+ {hotel.name} +
+ Check out: {formatDateShort(hotel.check_out)} + +
+
+ {/if} +
+ {/each} + + + {#if unscheduled.length > 0} +
+
+
+ + + +
+
+
Unscheduled
+
Not assigned to a day
+
+
+
+ {#each unscheduled as item} + {@const d = item.data} +
+
+
+ {item.type} + {d.name || d.flight_number || 'Untitled'} +
+
+
+ {/each} +
+
+ {/if} + + + {#if days.every(d => d.items.length === 0) && unscheduled.length === 0} +
+ + + +

No items yet

+

Tap + to start planning

+
+ {/if} +
+
+ + +
+ {#if fabOpen} +
+ + + + + +
+ {/if} + +
+ + + {#if fabOpen} +
fabOpen = false}>
+ {/if} + + + {#if showAIGuide} + showAIGuide = false} /> + {/if} + {#if showMapModal} + showMapModal = false} /> + {/if} + {#if showParseModal} + { showParseModal = false; loadTrip(tripId); }} onClose={() => showParseModal = false} /> + {/if} + {#if showTripEditModal} + { showTripEditModal = false; loadTrip(tripId); }} onClose={() => showTripEditModal = false} /> + {/if} + {#if showLocationModal} + + {/if} + {#if showLodgingModal} + + {/if} + {#if showTransportModal} + + {/if} + {#if showNoteModal} + + {/if} +{/if} diff --git a/services/trips/frontend-legacy/src/routes/view/[token]/+page.svelte b/services/trips/frontend-legacy/src/routes/view/[token]/+page.svelte new file mode 100644 index 0000000..d890808 --- /dev/null +++ b/services/trips/frontend-legacy/src/routes/view/[token]/+page.svelte @@ -0,0 +1,264 @@ + + + + {trip?.name || 'Shared Trip'} + + +{#if loading} +
+ +
+{:else if error || !trip} +
+ +

{error || 'Trip not found'}

+
+{:else} + + {#if trip.hero_images && trip.hero_images.length > 0} +
+ {#each trip.hero_images as img, i} +
+ +
+ {/each} +
+
+

{trip.name}

+ {#if trip.start_date} + + {formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)} + + {/if} +
+ {#if trip.hero_images.length > 1} +
+ {currentSlide + 1} / {trip.hero_images.length} +
+ {/if} +
+ {/if} + +
+ +
+ + + Shared Trip + +
+ + {#if !trip.start_date} +

{trip.name}

+ {/if} + + +
+ {#each days as day} + {#if day.items.length > 0} +
+
+
+ {day.num} +
+
+
{day.label}
+
Day {day.num} · {day.items.length} items
+
+
+ +
+ {#each day.items as item} + {#if item.type === 'transport'} + {@const t = item.data} +
+
+
+
+ +
+
+
{t.name || t.flight_number || `${t.from_location} → ${t.to_location}`}
+
{t.from_location} → {t.to_location}
+
+ {#if t.date}
{formatTime(t.date)}
{/if} +
+
+
+ {:else if item.type === 'location'} + {@const loc = item.data} +
+ {#if loc.images?.length > 0} +
+ {/if} +
+
+ {loc.name} + {#if loc.category}{loc.category}{/if} +
+ {#if loc.address}
{loc.address}
{/if} + {#if loc.description}
{@html loc.description}
{/if} +
+
+ {:else if item.type === 'note'} + {@const n = item.data} +
+
+
{n.name}
+ {#if n.content}
{@html n.content}
{/if} +
+
+ {/if} + {/each} +
+ + {#if overnightByDate.has(day.date)} + {@const hotel = overnightByDate.get(day.date)} +
+
+ + Staying overnight +
+
+ {hotel.name} +
+
+ {/if} +
+ {/if} + {/each} +
+
+{/if} diff --git a/services/trips/frontend-legacy/static/robots.txt b/services/trips/frontend-legacy/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/services/trips/frontend-legacy/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/services/trips/frontend-legacy/svelte.config.js b/services/trips/frontend-legacy/svelte.config.js new file mode 100644 index 0000000..ad116dc --- /dev/null +++ b/services/trips/frontend-legacy/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/services/trips/frontend-legacy/tsconfig.json b/services/trips/frontend-legacy/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/services/trips/frontend-legacy/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/services/trips/frontend-legacy/vite.config.ts b/services/trips/frontend-legacy/vite.config.ts new file mode 100644 index 0000000..00bc4ea --- /dev/null +++ b/services/trips/frontend-legacy/vite.config.ts @@ -0,0 +1,19 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8087', + changeOrigin: true + }, + '/images': { + target: 'http://localhost:8087', + changeOrigin: true + } + } + } +}); diff --git a/services/trips/manifest.json b/services/trips/manifest.json new file mode 100644 index 0000000..1a63b78 --- /dev/null +++ b/services/trips/manifest.json @@ -0,0 +1,33 @@ +{ + "name": "Trips - Travel Planner", + "short_name": "Trips", + "description": "Self-hosted trip planner with offline support", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#4a9eff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["travel", "productivity"], + "screenshots": [], + "shortcuts": [ + { + "name": "All Trips", + "url": "/", + "description": "View all trips" + } + ] +} diff --git a/services/trips/server.py b/services/trips/server.py new file mode 100644 index 0000000..df99cf1 --- /dev/null +++ b/services/trips/server.py @@ -0,0 +1,5356 @@ +#!/usr/bin/env python3 +""" +Trips - Self-hosted trip planner with SQLite backend +""" + +import os +import json +import sqlite3 +import uuid +import hashlib +import secrets +import shutil +import urllib.request +import urllib.parse +import urllib.error +import ssl +import base64 +import io +from http.server import HTTPServer, BaseHTTPRequestHandler +from http.cookies import SimpleCookie +from datetime import datetime, date, timedelta +from pathlib import Path +import mimetypes +import re +import html +import threading +import time + +# PDF support (optional) +try: + import PyPDF2 + PDF_SUPPORT = True +except ImportError: + PDF_SUPPORT = False + +# Configuration +PORT = int(os.environ.get("PORT", 8086)) +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DB_PATH = DATA_DIR / "trips.db" +IMAGES_DIR = DATA_DIR / "images" +USERNAME = os.environ.get("USERNAME", "admin") +PASSWORD = os.environ.get("PASSWORD", "admin") +GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "") +GOOGLE_CX = os.environ.get("GOOGLE_CX", "") +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") +OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2") +GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") +EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY", "") # API key for email worker +TRIPS_API_KEY = os.environ.get("TRIPS_API_KEY", "") # Bearer token for service-to-service API access +TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") +IMMICH_URL = os.environ.get("IMMICH_URL", "") +IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "") + +# OIDC Configuration (Pocket ID) +OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "") # e.g., https://pocket.quadjourney.com +OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "") +OIDC_REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "") # e.g., https://trips.quadjourney.com/auth/callback + +# Ensure directories exist +DATA_DIR.mkdir(parents=True, exist_ok=True) +IMAGES_DIR.mkdir(parents=True, exist_ok=True) +DOCS_DIR = DATA_DIR / "documents" +DOCS_DIR.mkdir(parents=True, exist_ok=True) + +# Session storage (now uses database for persistence) + +def get_db(): + """Get database connection.""" + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + +def init_db(): + """Initialize database schema.""" + conn = get_db() + cursor = conn.cursor() + + # Trips table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS trips ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + start_date TEXT, + end_date TEXT, + image_path TEXT, + share_token TEXT UNIQUE, + ai_suggestions TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Add ai_suggestions column if it doesn't exist (migration) + try: + cursor.execute("ALTER TABLE trips ADD COLUMN ai_suggestions TEXT") + conn.commit() + except: + pass # Column already exists + + # Add ai_suggestions_openai column for OpenAI suggestions (migration) + try: + cursor.execute("ALTER TABLE trips ADD COLUMN ai_suggestions_openai TEXT") + conn.commit() + except: + pass # Column already exists + + # Add immich_album_id column for slideshow (migration) + try: + cursor.execute("ALTER TABLE trips ADD COLUMN immich_album_id TEXT") + conn.commit() + except: + pass # Column already exists + + # Transportations table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS transportations ( + id TEXT PRIMARY KEY, + trip_id TEXT NOT NULL, + name TEXT, + type TEXT DEFAULT 'plane', + flight_number TEXT, + from_location TEXT, + to_location TEXT, + date TEXT, + end_date TEXT, + timezone TEXT, + description TEXT, + link TEXT, + cost_points REAL DEFAULT 0, + cost_cash REAL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + ) + ''') + + # Lodging table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS lodging ( + id TEXT PRIMARY KEY, + trip_id TEXT NOT NULL, + name TEXT, + type TEXT DEFAULT 'hotel', + location TEXT, + check_in TEXT, + check_out TEXT, + timezone TEXT, + reservation_number TEXT, + description TEXT, + link TEXT, + cost_points REAL DEFAULT 0, + cost_cash REAL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + ) + ''') + + # Notes table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + trip_id TEXT NOT NULL, + name TEXT, + content TEXT, + date TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + ) + ''') + + # Locations table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS locations ( + id TEXT PRIMARY KEY, + trip_id TEXT NOT NULL, + name TEXT, + description TEXT, + latitude REAL, + longitude REAL, + category TEXT, + visit_date TEXT, + start_time TEXT, + end_time TEXT, + link TEXT, + cost_points REAL DEFAULT 0, + cost_cash REAL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + ) + ''') + + # Add columns if they don't exist (migration for existing databases) + for table in ['locations', 'transportations', 'lodging']: + for col, col_type in [('start_time', 'TEXT'), ('end_time', 'TEXT'), ('link', 'TEXT'), + ('cost_points', 'REAL DEFAULT 0'), ('cost_cash', 'REAL DEFAULT 0')]: + try: + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_type}") + except: + pass + + # Add share_password to trips table + try: + cursor.execute("ALTER TABLE trips ADD COLUMN share_password TEXT") + except: + pass + + # Add hike-specific fields to locations table + for col, col_type in [('hike_distance', 'TEXT'), ('hike_difficulty', 'TEXT'), ('hike_time', 'TEXT')]: + try: + cursor.execute(f"ALTER TABLE locations ADD COLUMN {col} {col_type}") + except: + pass + + # Add lat/lng to lodging table for map display + for col, col_type in [('latitude', 'REAL'), ('longitude', 'REAL')]: + try: + cursor.execute(f"ALTER TABLE lodging ADD COLUMN {col} {col_type}") + except: + pass + + # Add lat/lng for transportation from/to locations + for col, col_type in [('from_lat', 'REAL'), ('from_lng', 'REAL'), ('to_lat', 'REAL'), ('to_lng', 'REAL')]: + try: + cursor.execute(f"ALTER TABLE transportations ADD COLUMN {col} {col_type}") + except: + pass + + # Add place_id and address to locations table for Google Places integration + for col, col_type in [('place_id', 'TEXT'), ('address', 'TEXT')]: + try: + cursor.execute(f"ALTER TABLE locations ADD COLUMN {col} {col_type}") + except: + pass + + # Add place_id to lodging table for Google Places integration + try: + cursor.execute("ALTER TABLE lodging ADD COLUMN place_id TEXT") + except: + pass + + # Add place_id for transportation from/to locations + for col in ['from_place_id', 'to_place_id']: + try: + cursor.execute(f"ALTER TABLE transportations ADD COLUMN {col} TEXT") + except: + pass + + # Images table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + file_path TEXT NOT NULL, + is_primary INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Documents table (for confirmation docs, PDFs, etc.) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Sessions table (for persistent login sessions) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Pending imports table (for email-parsed bookings awaiting review) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS pending_imports ( + id TEXT PRIMARY KEY, + entry_type TEXT NOT NULL, + parsed_data TEXT NOT NULL, + source TEXT DEFAULT 'email', + email_subject TEXT, + email_from TEXT, + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Quick adds table (for quick capture entries) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS quick_adds ( + id TEXT PRIMARY KEY, + trip_id TEXT NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + place_id TEXT, + address TEXT, + latitude REAL, + longitude REAL, + photo_path TEXT, + note TEXT, + captured_at TEXT NOT NULL, + status TEXT DEFAULT 'pending', + attached_to_id TEXT, + attached_to_type TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trip_id) REFERENCES trips(id) + ) + ''') + + conn.commit() + conn.close() + + +# ==================== OpenAI Integration ==================== + +PARSE_SYSTEM_PROMPT = """You are a travel trip planner assistant. Parse user input about ANY travel-related item and extract structured data. + +The user may type naturally like "hiking at Garden of the Gods on Monday" or paste formal booking confirmations. Handle both. + +Return ONLY valid JSON with this structure: +{ + "type": "flight" | "car" | "train" | "hotel" | "location" | "note", + "confidence": 0.0-1.0, + "data": { + // For flights/trains/cars (type = "flight", "car", or "train"): + "name": "Description", + "flight_number": "AA1234", // or empty for cars/trains + "from_location": "Dallas, TX (DFW)", + "to_location": "New York, NY (LGA)", + "date": "2025-01-15T08:50:00", + "end_date": "2025-01-15T14:30:00", + "timezone": "America/Chicago", + "type": "plane", // or "car" or "train" + "description": "" + + // For hotels/lodging (type = "hotel"): + "name": "Specific property name like 'Alila Jabal Akhdar'", + "location": "Address or city", + "check_in": "2025-01-15T15:00:00", + "check_out": "2025-01-18T11:00:00", + "timezone": "America/New_York", + "reservation_number": "ABC123", + "type": "hotel", + "description": "" + + // For locations/activities/attractions/restaurants (type = "location"): + "name": "Place or activity name", + "category": "attraction" | "restaurant" | "cafe" | "bar" | "hike" | "shopping" | "beach", + "visit_date": "2025-01-15", + "start_time": "2025-01-15T10:00:00", + "end_time": "2025-01-15T12:00:00", + "address": "", + "description": "Any details about the activity" + + // For notes (type = "note") - ONLY use this for actual notes/reminders, NOT activities: + "name": "Note title", + "content": "Note content", + "date": "2025-01-15" + } +} + +Guidelines: +- IMPORTANT: Activities like hiking, dining, sightseeing, visiting attractions = type "location", NOT "note" +- "note" is ONLY for actual notes, reminders, or text that doesn't describe an activity/place +- For flights, infer timezone from airport codes +- For hotels, check-in defaults to 3PM, check-out 11AM +- For hotels, use the SPECIFIC property name, not the brand +- For activities without a specific time, estimate a reasonable time +- Car rentals and rides = type "car" +- Parse dates in any format. "Monday" = next Monday relative to trip dates +- Extract as much information as possible""" + + +def call_openai(messages, max_completion_tokens=2000): + """Call OpenAI API.""" + if not OPENAI_API_KEY: + return {"error": "OpenAI API key not configured"} + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + data = json.dumps({ + "model": OPENAI_MODEL, + "messages": messages, + "max_completion_tokens": max_completion_tokens, + "temperature": 0.1 + }).encode("utf-8") + + req = urllib.request.Request( + "https://api.openai.com/v1/chat/completions", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {OPENAI_API_KEY}" + }, + method="POST" + ) + + try: + with urllib.request.urlopen(req, context=ssl_context, timeout=120) as response: + result = json.loads(response.read().decode()) + return result["choices"][0]["message"]["content"] + except Exception as e: + return {"error": f"OpenAI API error: {str(e)}"} + + +def call_gemini(prompt, use_search_grounding=True): + """Call Gemini 3 Pro API with optional Google Search grounding.""" + if not GEMINI_API_KEY: + return {"error": "Gemini API key not configured"} + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Build request body + request_body = { + "contents": [{ + "parts": [{"text": prompt}] + }], + "generationConfig": { + "temperature": 0.7, + "maxOutputTokens": 4096 + } + } + + # Add Google Search grounding for real-time research + if use_search_grounding: + request_body["tools"] = [{ + "google_search": {} + }] + + data = json.dumps(request_body).encode("utf-8") + + # Use Gemini 3 Pro (latest Pro model) + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent?key={GEMINI_API_KEY}" + + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST" + ) + + try: + with urllib.request.urlopen(req, context=ssl_context, timeout=120) as response: + result = json.loads(response.read().decode()) + # Extract text from response + if "candidates" in result and result["candidates"]: + candidate = result["candidates"][0] + if "content" in candidate and "parts" in candidate["content"]: + text_parts = [p.get("text", "") for p in candidate["content"]["parts"] if "text" in p] + return "".join(text_parts) + return {"error": "No response from Gemini"} + except urllib.error.HTTPError as e: + error_body = e.read().decode() if e.fp else "" + return {"error": f"Gemini API error: {e.code} - {error_body[:500]}"} + except Exception as e: + return {"error": f"Gemini API error: {str(e)}"} + + +def is_airport_code(text): + """Check if text looks like an airport code (3 uppercase letters).""" + if not text: + return False + text = text.strip() + return len(text) == 3 and text.isalpha() and text.isupper() + +def format_location_for_geocoding(location, is_plane=False): + """Format a location for geocoding, adding 'international airport' for airport codes.""" + if not location: + return location + location = location.strip() + # If it's a 3-letter airport code, add "international airport" + if is_airport_code(location) or is_plane: + if is_airport_code(location): + return f"{location} international airport" + # For planes, check if the location contains an airport code in parentheses like "Muscat (MCT)" + elif is_plane and not "airport" in location.lower(): + return f"{location} international airport" + return location + +def get_place_details(place_id): + """Get place details (lat, lng, address, name) from Google Places API (New). + Returns dict with latitude, longitude, address, name, types or empty dict on failure.""" + if not GOOGLE_API_KEY or not place_id: + return {} + + try: + url = f"https://places.googleapis.com/v1/places/{place_id}" + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request(url) + req.add_header('X-Goog-Api-Key', GOOGLE_API_KEY) + req.add_header('X-Goog-FieldMask', 'displayName,formattedAddress,location,types,primaryType') + + with urllib.request.urlopen(req, context=ssl_context, timeout=10) as response: + place = json.loads(response.read().decode()) + location = place.get("location", {}) + return { + "latitude": location.get("latitude"), + "longitude": location.get("longitude"), + "address": place.get("formattedAddress", ""), + "name": place.get("displayName", {}).get("text", ""), + "types": place.get("types", []), + "primary_type": place.get("primaryType", ""), + } + except Exception as e: + print(f"Place details error for '{place_id}': {e}") + return {} + + +def auto_resolve_place(name, address=""): + """Auto-lookup place_id from name using Places Autocomplete, then get details. + Returns (place_id, lat, lng, address) or (None, None, None, None).""" + if not GOOGLE_API_KEY or not name: + return None, None, None, None + try: + query = f"{name} {address}".strip() if address else name + url = "https://places.googleapis.com/v1/places:autocomplete" + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + payload = json.dumps({"input": query}).encode() + req = urllib.request.Request(url, data=payload, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("X-Goog-Api-Key", GOOGLE_API_KEY) + with urllib.request.urlopen(req, context=ssl_context, timeout=10) as response: + data = json.loads(response.read().decode()) + if data.get("suggestions"): + first = data["suggestions"][0].get("placePrediction", {}) + pid = first.get("place", "").replace("places/", "") + if not pid: + pid = first.get("placeId", "") + if pid: + details = get_place_details(pid) + return pid, details.get("latitude"), details.get("longitude"), details.get("address", "") + except Exception as e: + print(f"Auto-resolve place error for '{name}': {e}") + return None, None, None, None + + +def geocode_address(address): + """Geocode an address using Google Maps API. Returns (lat, lng) or (None, None). + Fallback for entries without a place_id - prefer get_place_details() when possible.""" + if not GOOGLE_API_KEY or not address: + return None, None + + try: + encoded_address = urllib.parse.quote(address) + url = f"https://maps.googleapis.com/maps/api/geocode/json?address={encoded_address}&key={GOOGLE_API_KEY}" + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request(url) + with urllib.request.urlopen(req, context=ssl_context, timeout=10) as response: + data = json.loads(response.read().decode()) + + if data.get("status") == "OK" and data.get("results"): + location = data["results"][0]["geometry"]["location"] + return location["lat"], location["lng"] + except Exception as e: + print(f"Geocoding error for '{address}': {e}") + + return None, None + + +def extract_pdf_text(pdf_data): + """Extract text from PDF binary data (legacy fallback).""" + if not PDF_SUPPORT: + return None + + try: + pdf_file = io.BytesIO(pdf_data) + pdf_reader = PyPDF2.PdfReader(pdf_file) + text_parts = [] + for page in pdf_reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + return "\n".join(text_parts) + except Exception as e: + return None + + +def parse_pdf_input(pdf_base64, filename="document.pdf", trip_start_date=None, trip_end_date=None): + """Parse a PDF file using OpenAI Responses API (required for PDF support).""" + if not OPENAI_API_KEY: + return {"error": "OpenAI API key not configured"} + + context = "" + if trip_start_date and trip_end_date: + context = f"\nContext: This is for a trip from {trip_start_date} to {trip_end_date}." + + # Build the prompt with system instructions included + prompt_text = f"""{PARSE_SYSTEM_PROMPT}{context} + +Extract booking information from this PDF. Return structured JSON only, no markdown formatting.""" + + # Use Responses API format for PDF files + data = json.dumps({ + "model": OPENAI_MODEL, + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_file", + "filename": filename, + "file_data": f"data:application/pdf;base64,{pdf_base64}" + }, + { + "type": "input_text", + "text": prompt_text + } + ] + } + ] + }).encode("utf-8") + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request( + "https://api.openai.com/v1/responses", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {OPENAI_API_KEY}" + }, + method="POST" + ) + + try: + with urllib.request.urlopen(req, context=ssl_context, timeout=120) as response: + result_data = json.loads(response.read().decode()) + # Responses API returns: output[0].content[0].text + result = result_data["output"][0]["content"][0]["text"] + except urllib.error.HTTPError as e: + error_body = e.read().decode() if e.fp else "" + print(f"OpenAI API HTTP Error: {e.code} - {error_body}", flush=True) + return {"error": f"OpenAI API error: HTTP {e.code} - {error_body}"} + except Exception as e: + return {"error": f"OpenAI API error: {str(e)}"} + + try: + result = result.strip() + if result.startswith("```json"): + result = result[7:] + if result.startswith("```"): + result = result[3:] + if result.endswith("```"): + result = result[:-3] + return json.loads(result.strip()) + except json.JSONDecodeError as e: + return {"error": f"Failed to parse AI response: {str(e)}", "raw": result} + + +def parse_text_input(text, trip_start_date=None, trip_end_date=None): + """Parse natural language text into structured booking data.""" + context = "" + if trip_start_date and trip_end_date: + context = f"\nContext: This is for a trip from {trip_start_date} to {trip_end_date}. If the user mentions a day number (e.g., 'day 3'), calculate the actual date." + + messages = [ + {"role": "system", "content": PARSE_SYSTEM_PROMPT + context}, + {"role": "user", "content": text} + ] + + result = call_openai(messages) + if isinstance(result, dict) and "error" in result: + return result + + try: + # Clean up response - sometimes GPT wraps in markdown + result = result.strip() + if result.startswith("```json"): + result = result[7:] + if result.startswith("```"): + result = result[3:] + if result.endswith("```"): + result = result[:-3] + return json.loads(result.strip()) + except json.JSONDecodeError as e: + return {"error": f"Failed to parse AI response: {str(e)}", "raw": result} + + +def parse_image_input(image_base64, mime_type="image/jpeg", trip_start_date=None, trip_end_date=None): + """Parse an image (screenshot of booking) using OpenAI Vision.""" + context = "" + if trip_start_date and trip_end_date: + context = f"\nContext: This is for a trip from {trip_start_date} to {trip_end_date}." + + messages = [ + {"role": "system", "content": PARSE_SYSTEM_PROMPT + context}, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Extract booking information from this image. Return structured JSON." + }, + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{image_base64}" + } + } + ] + } + ] + + result = call_openai(messages, max_tokens=3000) + if isinstance(result, dict) and "error" in result: + return result + + try: + result = result.strip() + if result.startswith("```json"): + result = result[7:] + if result.startswith("```"): + result = result[3:] + if result.endswith("```"): + result = result[:-3] + return json.loads(result.strip()) + except json.JSONDecodeError as e: + return {"error": f"Failed to parse AI response: {str(e)}", "raw": result} + + +# ==================== Trail Info ==================== + +def fetch_trail_info(query, hints=""): + """Fetch trail info using GPT's training data.""" + # Extract trail name from URL for better prompting + trail_name = query + if "/trail/" in query: + trail_name = query.split("/trail/")[-1].replace("-", " ").replace("/", " ").strip() + + # Build context with hints + hint_context = "" + if hints: + hint_context = f"\n\nAlternative names/hints provided by user: {hints}" + + messages = [ + {"role": "system", "content": """You are a hiking trail information assistant. Given a trail name, URL, or description, provide accurate trail information based on your training data. + +You should recognize trails by: +- AllTrails URLs +- Trail names (exact or partial) +- Alternative names (e.g., "Cave of Hira" = "Jabal Al-Nour") +- Famous landmarks or pilgrimage routes +- Location descriptions + +Return ONLY a JSON object with these fields: +{ + "name": "Trail Name", + "distance": "X.X mi", + "difficulty": "easy|moderate|hard|expert", + "estimated_time": "X-X hours", + "elevation_gain": "XXX ft (optional)", + "trail_type": "Out & Back|Loop|Point to Point (optional)", + "found": true +} + +ONLY return {"found": false} if you truly have NO information about this trail or location. + +Important: +- Distance should be the TOTAL distance (round-trip for out-and-back trails) +- Use miles for distance, feet for elevation +- Difficulty: easy (flat, paved), moderate (some elevation), hard (steep/challenging), expert (technical) +- If you know the location but not exact AllTrails data, provide your best estimate based on known information about the hike/climb"""}, + {"role": "user", "content": f"Get trail information for: {query}\n\nExtracted name: {trail_name}{hint_context}"} + ] + + result = call_openai(messages, max_completion_tokens=500) + if isinstance(result, dict) and "error" in result: + return result + + try: + result = result.strip() + if result.startswith("```json"): + result = result[7:] + if result.startswith("```"): + result = result[3:] + if result.endswith("```"): + result = result[:-3] + return json.loads(result.strip()) + except json.JSONDecodeError as e: + return {"error": f"Failed to parse trail info: {str(e)}", "raw": result} + + +def generate_attraction_description(name, category="attraction", location=""): + """Generate a short description for an attraction using GPT.""" + if not OPENAI_API_KEY: + return {"error": "OpenAI API key not configured"} + + location_context = f" in {location}" if location else "" + + messages = [ + {"role": "system", "content": """You are a travel guide writer. Generate a concise, engaging description for tourist attractions. + +Guidelines: +- Write 2-3 sentences (50-80 words) +- Mention what makes it special or notable +- Include practical info if relevant (best time to visit, what to expect) +- Keep it informative but engaging +- Don't include opening hours or prices (they change) +- Write in present tense"""}, + {"role": "user", "content": f"Write a short description for: {name}{location_context} (Category: {category})"} + ] + + result = call_openai(messages, max_completion_tokens=200) + if isinstance(result, dict) and "error" in result: + return result + + return {"description": result.strip()} + + +# ==================== Telegram Notifications ==================== + +def send_telegram_notification(message): + """Send a notification via Telegram.""" + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: + print("[TELEGRAM] Not configured, skipping notification") + return False + + try: + url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" + data = json.dumps({ + "chat_id": TELEGRAM_CHAT_ID, + "text": message, + "parse_mode": "HTML" + }).encode("utf-8") + + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + return result.get("ok", False) + except Exception as e: + print(f"[TELEGRAM] Error sending notification: {e}") + return False + + +# ==================== Flight Status ==================== + +def get_flight_status(airline_code, flight_number, date_str=None): + """ + Fetch flight status from FlightAware by parsing embedded JSON data. + + Args: + airline_code: IATA airline code (e.g., 'SV', 'AA', 'DL') + flight_number: Flight number (e.g., '20', '1234') + date_str: Optional date in YYYY-MM-DD format (not currently used) + + Returns: + dict with flight status info or error + """ + try: + # FlightAware uses ICAO-style codes: SVA20 for Saudia 20 + # Common IATA to ICAO prefixes - add 'A' for most airlines + flight_ident = f"{airline_code}A{flight_number}" if len(airline_code) == 2 else f"{airline_code}{flight_number}" + + # Some airlines use different patterns + iata_to_icao = { + 'AA': 'AAL', 'DL': 'DAL', 'UA': 'UAL', 'WN': 'SWA', 'AS': 'ASA', + 'B6': 'JBU', 'NK': 'NKS', 'F9': 'FFT', 'SV': 'SVA', 'EK': 'UAE', + 'QR': 'QTR', 'BA': 'BAW', 'LH': 'DLH', 'AF': 'AFR', 'KL': 'KLM', + } + if airline_code.upper() in iata_to_icao: + flight_ident = f"{iata_to_icao[airline_code.upper()]}{flight_number}" + + url = f"https://www.flightaware.com/live/flight/{flight_ident}" + + req = urllib.request.Request(url, headers={ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + }) + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + with urllib.request.urlopen(req, context=ssl_context, timeout=15) as response: + html_content = response.read().decode('utf-8') + + # Extract trackpollBootstrap JSON + match = re.search(r'var trackpollBootstrap\s*=\s*(\{.*?\});\s*(?:var|)', html_content, re.DOTALL) + if not match: + return {"error": "Could not find flight data in response"} + + data = json.loads(match.group(1)) + + # Get flights from the data + flights = data.get('flights', {}) + if not flights: + return {"error": "No flight data found"} + + # Get the first (most recent) flight + flight_key = list(flights.keys())[0] + flight_data = flights[flight_key] + + # Get activity log with detailed flight info + activity = flight_data.get('activityLog', {}).get('flights', []) + if not activity: + return {"error": "No flight activity found"} + + # Find the flight matching the requested date + flight_info = activity[0] # Default to first/most recent + if date_str: + try: + target_date = datetime.strptime(date_str, "%Y-%m-%d").date() + for flight in activity: + gate_dep = flight.get('gateDepartureTimes', {}) + scheduled_ts = gate_dep.get('scheduled') + if scheduled_ts: + flight_date = datetime.fromtimestamp(scheduled_ts).date() + if flight_date == target_date: + flight_info = flight + break + except (ValueError, TypeError): + pass # Use default if date parsing fails + + origin = flight_info.get('origin', {}) + destination = flight_info.get('destination', {}) + + # Get times + takeoff_times = flight_info.get('takeoffTimes', {}) + landing_times = flight_info.get('landingTimes', {}) + gate_dep_times = flight_info.get('gateDepartureTimes', {}) + gate_arr_times = flight_info.get('gateArrivalTimes', {}) + + # Calculate delays (check estimated vs scheduled for pre-departure delays) + dep_delay = 0 + arr_delay = 0 + + # Check gate departure delay (estimated vs scheduled) + if gate_dep_times.get('scheduled'): + if gate_dep_times.get('actual'): + dep_delay = (gate_dep_times['actual'] - gate_dep_times['scheduled']) // 60 + elif gate_dep_times.get('estimated'): + dep_delay = (gate_dep_times['estimated'] - gate_dep_times['scheduled']) // 60 + + # Check arrival delay + if gate_arr_times.get('scheduled'): + if gate_arr_times.get('actual'): + arr_delay = (gate_arr_times['actual'] - gate_arr_times['scheduled']) // 60 + elif gate_arr_times.get('estimated'): + arr_delay = (gate_arr_times['estimated'] - gate_arr_times['scheduled']) // 60 + + # Determine status + status = "On Time" + if flight_info.get('cancelled'): + status = "Cancelled" + elif landing_times.get('actual') or gate_arr_times.get('actual'): + status = "Landed" + elif takeoff_times.get('actual'): + status = "En Route" + elif dep_delay >= 15 or arr_delay >= 15: + status = "Delayed" + + # Format times (12-hour format in airport local timezone) + def format_time(ts, tz_str=None): + if not ts: + return '' + try: + from zoneinfo import ZoneInfo + dt = datetime.fromtimestamp(ts, tz=ZoneInfo('UTC')) + if tz_str: + # FlightAware uses format like ":America/New_York" + tz_name = tz_str.lstrip(':') + dt = dt.astimezone(ZoneInfo(tz_name)) + return dt.strftime('%I:%M %p').lstrip('0') + except: + return '' + + # Get timezones for origin/destination + origin_tz = origin.get('TZ', '') + dest_tz = destination.get('TZ', '') + + result = { + "airline": airline_code.upper(), + "airline_code": airline_code.upper(), + "flight_number": str(flight_number), + "status": status, + "status_code": status[0], + "delay_departure_minutes": max(0, dep_delay), + "delay_arrival_minutes": max(0, arr_delay), + "departure": { + "airport": origin.get('iata', ''), + "airport_name": origin.get('friendlyName', ''), + "terminal": origin.get('terminal', ''), + "gate": origin.get('gate', ''), + "scheduled": format_time(gate_dep_times.get('scheduled') or takeoff_times.get('scheduled'), origin_tz), + "estimated": format_time(gate_dep_times.get('estimated'), origin_tz), + "actual": format_time(gate_dep_times.get('actual') or takeoff_times.get('actual'), origin_tz), + }, + "arrival": { + "airport": destination.get('iata', ''), + "airport_name": destination.get('friendlyName', ''), + "terminal": destination.get('terminal', ''), + "gate": destination.get('gate', ''), + "scheduled": format_time(gate_arr_times.get('scheduled') or landing_times.get('scheduled'), dest_tz), + "estimated": format_time(gate_arr_times.get('estimated'), dest_tz), + "actual": format_time(gate_arr_times.get('actual') or landing_times.get('actual'), dest_tz), + }, + "aircraft": flight_info.get('aircraftTypeFriendly', ''), + "duration": '', + "flightaware_url": url, + } + + return result + + except urllib.error.HTTPError as e: + return {"error": f"HTTP error: {e.code}"} + except urllib.error.URLError as e: + return {"error": f"URL error: {str(e)}"} + except json.JSONDecodeError as e: + return {"error": f"Failed to parse flight data: {str(e)}"} + except Exception as e: + return {"error": f"Failed to fetch flight status: {str(e)}"} + + +def parse_flight_number(flight_str): + """ + Parse a flight string into airline code and flight number. + Examples: 'SV20', 'SV 20', 'AA1234', 'DL 456' + """ + if not flight_str: + return None, None + + # Remove spaces and uppercase + clean = flight_str.strip().upper().replace(' ', '') + + # Match pattern: 2-3 letter airline code followed by 1-4 digit flight number + match = re.match(r'^([A-Z]{2,3})(\d{1,4})$', clean) + if match: + return match.group(1), match.group(2) + + return None, None + + +def dict_from_row(row): + """Convert sqlite3.Row to dict.""" + if row is None: + return None + return dict(row) + +def generate_id(): + """Generate a UUID.""" + return str(uuid.uuid4()) + + +def parse_location_string(loc): + """Parse a location string to extract geocodable location. + + For addresses like "142-30 135th Ave, Jamaica, New York USA, 11436", + returns "New York" as the geocodable city (Jamaica, Queens won't geocode properly). + """ + if not loc: + return None + parts = [p.strip() for p in loc.split(',')] + + if len(parts) >= 3: + # Check if first part looks like a street address (has numbers) + if any(c.isdigit() for c in parts[0]): + # For full addresses, use the state/major city for reliable geocoding + # e.g., "142-30 135th Ave, Jamaica, New York USA, 11436" -> "New York" + state_part = parts[2].strip() + # Remove "USA", country names, and zip codes + state_part = ' '.join(word for word in state_part.split() + if not word.isdigit() and word.upper() not in ('USA', 'US', 'UK')) + if state_part: + return state_part + # Fallback to city if state is empty + return parts[1] + return parts[0] + elif len(parts) == 2: + if any(c.isdigit() for c in parts[0]): + return parts[1] + return parts[0] + return parts[0] + + +def get_locations_for_date(trip, date_str): + """Get locations for a specific date, including travel days with two cities. + + Returns a list of location dicts: + [{"location": "geocodable string", "city": "Display Name", "type": "lodging|departure|arrival"}] + + For travel days (flight departing), returns both origin and destination. + """ + try: + target_date = datetime.strptime(date_str, '%Y-%m-%d').date() + except: + loc = get_trip_location(trip) + return [{"location": loc, "city": loc, "type": "fallback"}] + + locations = [] + found_lodging = None + found_transport = None + + # Check for lodging that spans this date + for lodging in trip.get('lodging', []): + check_in = lodging.get('check_in', '') + check_out = lodging.get('check_out', '') + + if check_in and check_out: + try: + # Handle datetime with timezone like "2025-12-24T15:00|America/New_York" + check_in_clean = check_in.split('|')[0].replace('Z', '') + check_out_clean = check_out.split('|')[0].replace('Z', '') + check_in_date = datetime.fromisoformat(check_in_clean).date() + check_out_date = datetime.fromisoformat(check_out_clean).date() + + if check_in_date <= target_date < check_out_date: + loc = parse_location_string(lodging.get('location', '')) + if loc: + found_lodging = {"location": loc, "city": loc, "type": "lodging"} + break + except: + continue + + # Check for transportation on this date (departures and arrivals) + for transport in trip.get('transportations', []): + transport_date = transport.get('date', '') + if transport_date: + try: + t_date_clean = transport_date.split('|')[0].replace('Z', '') + t_date = datetime.fromisoformat(t_date_clean).date() + if t_date == target_date: + from_loc = transport.get('from_location', '').strip() + to_loc = transport.get('to_location', '').strip() + if from_loc and to_loc: + found_transport = { + "from": from_loc.split(',')[0].strip(), + "to": to_loc.split(',')[0].strip() + } + break + except: + continue + + # Build result based on what we found + if found_transport: + # Travel day - show both cities + locations.append({ + "location": found_transport["from"], + "city": found_transport["from"], + "type": "departure" + }) + locations.append({ + "location": found_transport["to"], + "city": found_transport["to"], + "type": "arrival" + }) + elif found_lodging: + locations.append(found_lodging) + else: + # Fallback to first lodging or transport + for lodging in trip.get('lodging', []): + loc = parse_location_string(lodging.get('location', '')) + if loc: + locations.append({"location": loc, "city": loc, "type": "lodging"}) + break + + if not locations: + for transport in trip.get('transportations', []): + to_loc = transport.get('to_location', '').strip() + if to_loc: + city = to_loc.split(',')[0].strip() + locations.append({"location": city, "city": city, "type": "fallback"}) + break + + if not locations: + name = trip.get('name', '').strip() + locations.append({"location": name, "city": name, "type": "fallback"}) + + return locations + + +def get_location_for_date(trip, date_str): + """Get single location for a specific date (legacy wrapper).""" + locations = get_locations_for_date(trip, date_str) + if locations: + return locations[0]["location"] + return get_trip_location(trip) + + +def get_trip_location(trip): + """Get primary location for a trip (for weather lookup).""" + # Try lodging locations first + for lodging in trip.get('lodging', []): + loc = parse_location_string(lodging.get('location', '')) + if loc: + return loc + + # Try transportation destinations (airport codes work with geocoding API) + for transport in trip.get('transportations', []): + to_loc = transport.get('to_location', '').strip() + if to_loc: + return to_loc.split(',')[0].strip() + + # Fall back to trip name + return trip.get('name', '').strip() + + +def hash_password(password): + """Hash a password.""" + return hashlib.sha256(password.encode()).hexdigest() + +def create_session(username): + """Create a new session in the database.""" + token = secrets.token_hex(32) + conn = get_db() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO sessions (token, username) VALUES (?, ?)", + (token, username) + ) + conn.commit() + conn.close() + return token + +def verify_session(token): + """Verify a session token exists in the database.""" + if not token: + return False + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT token FROM sessions WHERE token = ?", (token,)) + result = cursor.fetchone() + conn.close() + return result is not None + +def delete_session(token): + """Delete a session from the database.""" + if not token: + return + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + conn.close() + +# ==================== OIDC Functions ==================== + +def get_oidc_config(): + """Fetch OIDC discovery document from the issuer.""" + if not OIDC_ISSUER: + return None + try: + url = f"{OIDC_ISSUER}/.well-known/openid-configuration" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode()) + except Exception as e: + print(f"Failed to fetch OIDC config: {e}") + return None + +def get_oidc_authorization_url(state): + """Build the OIDC authorization URL.""" + config = get_oidc_config() + if not config: + return None + + auth_endpoint = config.get("authorization_endpoint") + if not auth_endpoint: + return None + + params = { + "client_id": OIDC_CLIENT_ID, + "redirect_uri": OIDC_REDIRECT_URI, + "response_type": "code", + "scope": "openid email profile", + "state": state, + } + return f"{auth_endpoint}?{urllib.parse.urlencode(params)}" + +def exchange_oidc_code(code): + """Exchange authorization code for tokens.""" + config = get_oidc_config() + if not config: + return None + + token_endpoint = config.get("token_endpoint") + if not token_endpoint: + return None + + data = urllib.parse.urlencode({ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": OIDC_REDIRECT_URI, + "client_id": OIDC_CLIENT_ID, + "client_secret": OIDC_CLIENT_SECRET, + }).encode() + + try: + req = urllib.request.Request(token_endpoint, data=data, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode()) + except Exception as e: + print(f"Failed to exchange OIDC code: {e}") + return None + +def get_oidc_userinfo(access_token): + """Fetch user info from OIDC provider.""" + config = get_oidc_config() + if not config: + return None + + userinfo_endpoint = config.get("userinfo_endpoint") + if not userinfo_endpoint: + return None + + try: + req = urllib.request.Request(userinfo_endpoint) + req.add_header("Authorization", f"Bearer {access_token}") + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode()) + except Exception as e: + print(f"Failed to fetch OIDC userinfo: {e}") + return None + +def get_images_for_entity(entity_type, entity_id): + """Get all images for an entity.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM images WHERE entity_type = ? AND entity_id = ? ORDER BY is_primary DESC, created_at", + (entity_type, entity_id) + ) + images = [dict_from_row(row) for row in cursor.fetchall()] + conn.close() + return images + +def get_documents_for_entity(entity_type, entity_id): + """Get all documents for an entity.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM documents WHERE entity_type = ? AND entity_id = ? ORDER BY created_at", + (entity_type, entity_id) + ) + docs = [dict_from_row(row) for row in cursor.fetchall()] + conn.close() + return docs + +def get_primary_image(entity_type, entity_id): + """Get primary image for an entity.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM images WHERE entity_type = ? AND entity_id = ? ORDER BY is_primary DESC, created_at LIMIT 1", + (entity_type, entity_id) + ) + row = cursor.fetchone() + conn.close() + return dict_from_row(row) if row else None + +def parse_datetime(dt_str): + """Parse datetime string, return (datetime_obj, display_str).""" + if not dt_str: + return None, "" + try: + # Handle format: 2026-01-20T08:00|Asia/Muscat or just 2026-01-20T08:00 + if "|" in dt_str: + dt_part, tz = dt_str.split("|", 1) + else: + dt_part = dt_str + tz = "" + + dt = datetime.fromisoformat(dt_part.replace("Z", "")) + display = dt.strftime("%b %d, %Y, %I:%M %p") + if tz: + display += f" ({tz})" + return dt, display + except: + return None, dt_str + +def get_date_from_datetime(dt_str): + """Extract date from datetime string.""" + if not dt_str: + return None + try: + if "|" in dt_str: + dt_part = dt_str.split("|")[0] + else: + dt_part = dt_str + return datetime.fromisoformat(dt_part.replace("Z", "")).date() + except: + return None + + +def render_docs_html(docs): + """Generate HTML for documents list in card view.""" + if not docs: + return "" + + doc_items = [] + for doc in docs: + ext = doc["file_name"].rsplit(".", 1)[-1].lower() if "." in doc["file_name"] else "" + if ext == "pdf": + icon = "📄" + elif ext in ["doc", "docx"]: + icon = "📃" + elif ext in ["jpg", "jpeg", "png", "gif", "webp"]: + icon = "📷" + else: + icon = "📄" + + doc_items.append(f'{icon} {html.escape(doc["file_name"])}') + + return f'
Documents: {"".join(doc_items)}
' + + +def hotel_names_match(name1, name2): + """Check if two hotel names are similar enough to be the same hotel.""" + filler_words = {'by', 'the', 'a', 'an', 'hotel', 'hotels', 'resort', 'resorts', + 'suites', 'suite', '&', 'and', '-', 'inn'} + + def normalize_words(name): + words = name.lower().split() + return set(w for w in words if w not in filler_words and len(w) > 1) + + words1 = normalize_words(name1) + words2 = normalize_words(name2) + + if not words1 or not words2: + return name1.lower().strip() == name2.lower().strip() + + common = words1 & words2 + smaller_set = min(len(words1), len(words2)) + match_ratio = len(common) / smaller_set if smaller_set > 0 else 0 + return len(common) >= 2 and match_ratio >= 0.6 + + +def find_duplicate_flight(trip_id, flight_number, flight_date): + """Check if a similar flight already exists in the trip.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT * FROM transportations WHERE trip_id = ?", (trip_id,)) + transportations = [dict_from_row(row) for row in cursor.fetchall()] + conn.close() + + flight_number_normalized = flight_number.replace(" ", "").upper() if flight_number else "" + + target_date = None + if flight_date: + try: + if "|" in flight_date: + flight_date = flight_date.split("|")[0] + if "T" in flight_date: + target_date = flight_date.split("T")[0] + else: + target_date = flight_date[:10] + except: + pass + + for t in transportations: + existing_flight_num = (t.get("flight_number") or "").replace(" ", "").upper() + existing_date = t.get("date", "") + + if existing_date: + if "|" in existing_date: + existing_date = existing_date.split("|")[0] + if "T" in existing_date: + existing_date = existing_date.split("T")[0] + + if existing_flight_num and flight_number_normalized: + if existing_flight_num == flight_number_normalized: + if target_date and existing_date and target_date == existing_date: + return t + + return None + + +def find_duplicate_hotel(trip_id, hotel_name, check_in_date, reservation_number=None): + """Check if a similar hotel already exists in the trip.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT * FROM lodging WHERE trip_id = ?", (trip_id,)) + lodging = [dict_from_row(row) for row in cursor.fetchall()] + conn.close() + + hotel_name_normalized = hotel_name.lower().strip() if hotel_name else "" + + target_date = None + if check_in_date: + try: + if "|" in check_in_date: + check_in_date = check_in_date.split("|")[0] + if "T" in check_in_date: + target_date = check_in_date.split("T")[0] + else: + target_date = check_in_date[:10] + except: + pass + + for l in lodging: + existing_name = (l.get("name") or "").lower().strip() + existing_date = l.get("check_in", "") + existing_res_num = l.get("reservation_number", "") + + if existing_date: + if "|" in existing_date: + existing_date = existing_date.split("|")[0] + if "T" in existing_date: + existing_date = existing_date.split("T")[0] + + # Match by reservation number if provided + if reservation_number and existing_res_num: + if reservation_number.strip() == existing_res_num.strip(): + return l + + # Match by hotel name + check-in date + if existing_name and hotel_name_normalized: + name_match = hotel_names_match(existing_name, hotel_name_normalized) + date_match = (target_date and existing_date and target_date == existing_date) + if name_match and date_match: + return l + + return None + + +# Weather cache (in-memory, clears on restart) +# {location_name: {"coords": (lat, lon, timezone), "expires": timestamp}} +GEOCODE_CACHE = {} +# {(lat, lon): {"data": weather_data, "expires": timestamp}} +WEATHER_CACHE = {} +CACHE_TTL = 3600 # 1 hour + +# Weather code to icon/description mapping (WMO codes) +WEATHER_CODES = { + 0: ("☀️", "Clear"), + 1: ("🌤️", "Mostly Clear"), + 2: ("⛅", "Partly Cloudy"), + 3: ("☁️", "Cloudy"), + 45: ("🌫️", "Foggy"), + 48: ("🌫️", "Icy Fog"), + 51: ("🌧️", "Light Drizzle"), + 53: ("🌧️", "Drizzle"), + 55: ("🌧️", "Heavy Drizzle"), + 61: ("🌧️", "Light Rain"), + 63: ("🌧️", "Rain"), + 65: ("🌧️", "Heavy Rain"), + 71: ("🌨️", "Light Snow"), + 73: ("🌨️", "Snow"), + 75: ("❄️", "Heavy Snow"), + 77: ("🌨️", "Snow Grains"), + 80: ("🌦️", "Light Showers"), + 81: ("🌦️", "Showers"), + 82: ("⛈️", "Heavy Showers"), + 85: ("🌨️", "Snow Showers"), + 86: ("🌨️", "Heavy Snow Showers"), + 95: ("⛈️", "Thunderstorm"), + 96: ("⛈️", "Thunderstorm + Hail"), + 99: ("⛈️", "Severe Thunderstorm"), +} + + +def get_weather_forecast(location_name, dates): + """ + Fetch weather forecast for a location and list of dates using Open-Meteo API. + Uses caching to avoid redundant API calls. + + Args: + location_name: City/location name to geocode + dates: List of date strings in YYYY-MM-DD format + + Returns: + dict with date -> weather info mapping + """ + import time + now = time.time() + + try: + # Step 1: Geocode the location (with cache) + cache_key = location_name.lower().strip() + cached_geo = GEOCODE_CACHE.get(cache_key) + + if cached_geo and cached_geo.get("expires", 0) > now: + if cached_geo["coords"] is None: + # Cached failure - skip this location + return {"error": f"Location not found (cached): {location_name}"} + lat, lon, timezone = cached_geo["coords"] + else: + geo_url = f"https://geocoding-api.open-meteo.com/v1/search?name={urllib.parse.quote(location_name)}&count=1" + + req = urllib.request.Request(geo_url, headers={ + 'User-Agent': 'Mozilla/5.0 (compatible; TripPlanner/1.0)' + }) + + with urllib.request.urlopen(req, timeout=10) as response: + geo_data = json.loads(response.read().decode('utf-8')) + + if not geo_data.get('results'): + # Cache the failure to avoid retrying + GEOCODE_CACHE[cache_key] = {"coords": None, "expires": now + 86400} + return {"error": f"Location not found: {location_name}"} + + lat = geo_data['results'][0]['latitude'] + lon = geo_data['results'][0]['longitude'] + timezone = geo_data['results'][0].get('timezone', 'auto') + + # Cache for 24 hours (geocode data doesn't change) + GEOCODE_CACHE[cache_key] = { + "coords": (lat, lon, timezone), + "expires": now + 86400 + } + + # Step 2: Fetch weather forecast (with cache) + weather_cache_key = (round(lat, 2), round(lon, 2)) # Round to reduce cache misses + cached_weather = WEATHER_CACHE.get(weather_cache_key) + + if cached_weather and cached_weather.get("expires", 0) > now: + daily = cached_weather["data"] + else: + weather_url = ( + f"https://api.open-meteo.com/v1/forecast?" + f"latitude={lat}&longitude={lon}" + f"&daily=weather_code,temperature_2m_max,temperature_2m_min" + f"&temperature_unit=fahrenheit" + f"&timezone={urllib.parse.quote(timezone)}" + f"&forecast_days=16" + ) + + req = urllib.request.Request(weather_url, headers={ + 'User-Agent': 'Mozilla/5.0 (compatible; TripPlanner/1.0)' + }) + + with urllib.request.urlopen(req, timeout=10) as response: + weather_data = json.loads(response.read().decode('utf-8')) + + if not weather_data.get('daily'): + return {"error": "No weather data available"} + + daily = weather_data['daily'] + + # Cache weather for 1 hour + WEATHER_CACHE[weather_cache_key] = { + "data": daily, + "expires": now + CACHE_TTL + } + + result = {} + for i, date in enumerate(daily.get('time', [])): + if date in dates: + code = daily['weather_code'][i] + icon, desc = WEATHER_CODES.get(code, ("❓", "Unknown")) + result[date] = { + "icon": icon, + "description": desc, + "high": round(daily['temperature_2m_max'][i]), + "low": round(daily['temperature_2m_min'][i]), + "code": code + } + + return {"location": location_name, "forecasts": result} + + except urllib.error.URLError as e: + return {"error": f"Failed to fetch weather: {str(e)}"} + except Exception as e: + return {"error": f"Weather error: {str(e)}"} + + +def prefetch_weather_for_trips(): + """Background job to prefetch weather for upcoming trips.""" + try: + conn = get_db() + cursor = conn.cursor() + + # Get trips with dates in the next 16 days (weather forecast limit) + today = date.today() + future = today + timedelta(days=16) + today_str = today.strftime('%Y-%m-%d') + future_str = future.strftime('%Y-%m-%d') + + cursor.execute(""" + SELECT * FROM trips + WHERE (start_date <= ? AND end_date >= ?) + OR (start_date >= ? AND start_date <= ?) + ORDER BY start_date + """, (future_str, today_str, today_str, future_str)) + + trips = [dict_from_row(row) for row in cursor.fetchall()] + + for trip in trips: + trip_id = trip['id'] + + # Load lodging and transportations + cursor.execute("SELECT * FROM lodging WHERE trip_id = ? ORDER BY check_in", (trip_id,)) + trip["lodging"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM transportations WHERE trip_id = ? ORDER BY date", (trip_id,)) + trip["transportations"] = [dict_from_row(row) for row in cursor.fetchall()] + + # Generate dates for this trip (within forecast window) + start = max(today, datetime.strptime(trip['start_date'], '%Y-%m-%d').date() if trip.get('start_date') else today) + end = min(future, datetime.strptime(trip['end_date'], '%Y-%m-%d').date() if trip.get('end_date') else future) + + dates = [] + current = start + while current <= end: + dates.append(current.strftime('%Y-%m-%d')) + current += timedelta(days=1) + + if not dates: + continue + + # Get unique locations for all dates + all_locations = set() + for date_str in dates: + locs = get_locations_for_date(trip, date_str) + for loc_info in locs: + all_locations.add(loc_info["location"]) + + # Prefetch weather for each location (this populates the cache) + for loc in all_locations: + get_weather_forecast(loc, dates) + time.sleep(0.5) # Be nice to the API + + conn.close() + print(f"[Weather] Prefetched weather for {len(trips)} upcoming trips", flush=True) + + except Exception as e: + print(f"[Weather] Prefetch error: {e}", flush=True) + + +def weather_prefetch_loop(): + """Background thread that periodically prefetches weather.""" + # Initial delay to let server start + time.sleep(10) + + while True: + try: + prefetch_weather_for_trips() + except Exception as e: + print(f"[Weather] Background job error: {e}", flush=True) + + # Run every 30 minutes + time.sleep(1800) + + +def start_weather_prefetch_thread(): + """Start the background weather prefetch thread.""" + thread = threading.Thread(target=weather_prefetch_loop, daemon=True) + thread.start() + print("[Weather] Background prefetch thread started", flush=True) + + +class TripHandler(BaseHTTPRequestHandler): + """HTTP request handler.""" + + def log_message(self, format, *args): + """Log HTTP requests.""" + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {args[0]}") + + def get_session(self): + """Get session from cookie.""" + cookie = SimpleCookie(self.headers.get("Cookie", "")) + if "session" in cookie: + return cookie["session"].value + return None + + def parse_cookies(self): + """Parse all cookies into a dictionary.""" + cookie = SimpleCookie(self.headers.get("Cookie", "")) + return {key: morsel.value for key, morsel in cookie.items()} + + def is_authenticated(self): + """Check if request is authenticated via session cookie or Bearer token.""" + # Check Bearer token first (service-to-service API access) + auth_header = self.headers.get("Authorization", "") + if auth_header.startswith("Bearer ") and TRIPS_API_KEY: + token = auth_header[7:].strip() + if token and token == TRIPS_API_KEY: + return True + + # Fall back to session cookie (web app) + session = self.get_session() + return session and verify_session(session) + + def send_html(self, content, status=200): + """Send HTML response.""" + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(content.encode()) + + def send_json(self, data, status=200): + """Send JSON response.""" + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def send_redirect(self, location): + """Send redirect response.""" + self.send_response(302) + self.send_header("Location", location) + self.end_headers() + + def serve_file(self, file_path): + """Serve a static file.""" + if not file_path.exists(): + self.send_error(404) + return + + mime_type, _ = mimetypes.guess_type(str(file_path)) + if not mime_type: + mime_type = "application/octet-stream" + + self.send_response(200) + self.send_header("Content-Type", mime_type) + self.send_header("Content-Length", file_path.stat().st_size) + self.end_headers() + + with open(file_path, "rb") as f: + shutil.copyfileobj(f, self.wfile) + + def serve_pwa_icon(self, size): + """Generate a simple PWA icon with airplane emoji.""" + # Create a simple SVG icon and convert to PNG-like response + # For simplicity, serve an SVG that browsers will accept + svg = f''' + + ✈️ + ''' + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.end_headers() + self.wfile.write(svg.encode()) + + def do_GET(self): + """Handle GET requests.""" + path = self.path.split("?")[0] + + # Static files (images) + if path.startswith("/images/"): + file_path = IMAGES_DIR / path[8:] + self.serve_file(file_path) + return + + # Static files (documents) + if path.startswith("/documents/"): + file_path = DOCS_DIR / path[11:] + self.serve_file(file_path) + return + + # Service worker + if path == "/sw.js": + sw_path = Path(__file__).parent / "sw.js" + self.send_response(200) + self.send_header("Content-Type", "application/javascript") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + with open(sw_path, "rb") as f: + self.wfile.write(f.read()) + return + + # PWA manifest + if path == "/manifest.json": + manifest_path = Path(__file__).parent / "manifest.json" + self.send_response(200) + self.send_header("Content-Type", "application/manifest+json") + self.end_headers() + with open(manifest_path, "rb") as f: + self.wfile.write(f.read()) + return + + # PWA icons (generate simple colored icons) + if path == "/icon-192.png" or path == "/icon-512.png": + size = 192 if "192" in path else 512 + self.serve_pwa_icon(size) + return + + # Public share view — redirect to SvelteKit frontend + if path.startswith("/share/"): + share_token = path[7:] + self.send_json({"redirect": f"/view/{share_token}"}) + return + + # Public share API (returns JSON for SvelteKit frontend) + if path.startswith("/api/share/trip/"): + share_token = path[16:] + self.handle_share_api(share_token) + return + + # Login page + if path == "/login": + if self.is_authenticated(): + self.send_redirect("/") + elif OIDC_ISSUER and OIDC_CLIENT_ID: + # Redirect to OIDC provider + # Reuse existing state if present to avoid race condition with multiple redirects + cookies = self.parse_cookies() + existing_state = cookies.get("oidc_state") + if existing_state: + state = existing_state + else: + state = secrets.token_hex(16) + auth_url = get_oidc_authorization_url(state) + if auth_url: + # Store state in a cookie for CSRF protection + self.send_response(302) + self.send_header("Location", auth_url) + if not existing_state: + self.send_header("Set-Cookie", f"oidc_state={state}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=600") + self.end_headers() + else: + self.send_json({"error": "OIDC configuration error"}, 500) + else: + self.send_json({"error": "Login via API token or OIDC"}, 200) + return + + # Logout + if path == "/logout": + session = self.get_session() + if session: + delete_session(session) + + # Get id_token for OIDC logout before clearing cookies + cookies = self.parse_cookies() + id_token = cookies.get("id_token", "") + + # Clear local session and id_token cookies + self.send_response(302) + self.send_header("Set-Cookie", "session=; Path=/; Max-Age=0") + self.send_header("Set-Cookie", "id_token=; Path=/; Max-Age=0") + + # If OIDC is configured, redirect to IdP logout + if OIDC_ISSUER: + config = get_oidc_config() + if config and config.get("end_session_endpoint"): + # Extract base URL (scheme + host) from redirect URI + parsed = urllib.parse.urlparse(OIDC_REDIRECT_URI) + base_url = f"{parsed.scheme}://{parsed.netloc}" + redirect_uri = urllib.parse.quote(f"{base_url}/login") + logout_url = f"{config['end_session_endpoint']}?client_id={OIDC_CLIENT_ID}&post_logout_redirect_uri={redirect_uri}" + if id_token: + logout_url += f"&id_token_hint={id_token}" + self.send_header("Location", logout_url) + self.end_headers() + return + + self.send_header("Location", "/login") + self.end_headers() + return + + # OIDC callback + if path.startswith("/auth/callback"): + self.handle_oidc_callback() + return + + # Protected routes + if not self.is_authenticated(): + # Return JSON 401 for API requests, redirect for browser + if path.startswith("/api/"): + self.send_json({"error": "Unauthorized"}, 401) + else: + self.send_redirect("/login") + return + + # Home — API only, frontend is SvelteKit + if path == "/" or path == "": + self.send_json({"status": "Trips API server", "endpoints": "/api/trips, /api/trip/{id}, /api/stats"}) + return + + # API routes + if path == "/api/trips": + self.handle_get_trips() + return + + if path.startswith("/api/trip/"): + trip_id = path[10:] + self.handle_get_trip(trip_id) + return + + if path == "/api/pending-imports": + self.handle_get_pending_imports() + return + + if path.startswith("/api/quick-adds"): + # Parse query params for trip_id + query_string = self.path.split("?")[1] if "?" in self.path else "" + params = urllib.parse.parse_qs(query_string) + trip_id = params.get("trip_id", [None])[0] + self.handle_get_quick_adds(trip_id) + return + + if path.startswith("/api/immich/thumb/"): + asset_id = path.split("/")[-1] + self.handle_immich_thumbnail(asset_id) + return + + if path.startswith("/api/places/details"): + query_string = self.path.split("?")[1] if "?" in self.path else "" + params = urllib.parse.parse_qs(query_string) + place_id = params.get("place_id", [None])[0] + self.handle_places_details(place_id) + return + + if path == "/api/active-trip": + self.handle_get_active_trip() + return + + if path == "/api/stats": + self.handle_get_stats() + return + + if path.startswith("/api/search"): + query_string = self.path.split("?")[1] if "?" in self.path else "" + params = urllib.parse.parse_qs(query_string) + q = params.get("q", [""])[0] + self.handle_search(q) + return + + self.send_error(404) + + def do_POST(self): + """Handle POST requests.""" + path = self.path + content_length = int(self.headers.get("Content-Length", 0)) + + # Login + if path == "/login": + self.handle_login(content_length) + return + + # Email parsing (API key auth) + if path == "/api/parse-email": + body = self.rfile.read(content_length) if content_length > 0 else b"" + self.handle_parse_email(body) + return + + # Share password verification (public - no auth required) + if path == "/api/share/verify": + body = self.rfile.read(content_length) if content_length > 0 else b"" + self.handle_share_verify(body) + return + + # Protected routes + if not self.is_authenticated(): + self.send_json({"error": "Unauthorized"}, 401) + return + + # Read body + body = self.rfile.read(content_length) if content_length > 0 else b"" + + # API routes + if path == "/api/trip": + self.handle_create_trip(body) + elif path == "/api/trip/update": + self.handle_update_trip(body) + elif path == "/api/trip/delete": + self.handle_delete_trip(body) + elif path == "/api/transportation": + self.handle_create_transportation(body) + elif path == "/api/transportation/update": + self.handle_update_transportation(body) + elif path == "/api/transportation/delete": + self.handle_delete_transportation(body) + elif path == "/api/lodging": + self.handle_create_lodging(body) + elif path == "/api/lodging/update": + self.handle_update_lodging(body) + elif path == "/api/lodging/delete": + self.handle_delete_lodging(body) + elif path == "/api/note": + self.handle_create_note(body) + elif path == "/api/note/update": + self.handle_update_note(body) + elif path == "/api/note/delete": + self.handle_delete_note(body) + elif path == "/api/location": + self.handle_create_location(body) + elif path == "/api/location/update": + self.handle_update_location(body) + elif path == "/api/location/delete": + self.handle_delete_location(body) + elif path == "/api/move-item": + self.handle_move_item(body) + elif path == "/api/image/upload": + self.handle_image_upload(body) + elif path == "/api/image/delete": + self.handle_image_delete(body) + elif path == "/api/image/search": + self.handle_image_search(body) + elif path == "/api/image/upload-from-url": + self.handle_image_upload_from_url(body) + elif path == "/api/share/create": + self.handle_create_share(body) + elif path == "/api/share/delete": + self.handle_delete_share(body) + elif path == "/api/share/verify": + self.handle_share_verify(body) + elif path == "/api/parse": + self.handle_parse(body) + elif path == "/api/document/upload": + self.handle_document_upload(body) + elif path == "/api/document/delete": + self.handle_document_delete(body) + elif path == "/api/check-duplicate": + self.handle_check_duplicate(body) + elif path == "/api/merge-entry": + self.handle_merge_entry(body) + elif path == "/api/flight-status": + self.handle_flight_status(body) + elif path == "/api/weather": + self.handle_weather(body) + elif path == "/api/trail-info": + self.handle_trail_info(body) + elif path == "/api/generate-description": + self.handle_generate_description(body) + elif path == "/api/pending-imports/approve": + self.handle_approve_import(body) + elif path == "/api/pending-imports/delete": + self.handle_delete_import(body) + elif path == "/api/geocode-all": + self.handle_geocode_all() + elif path == "/api/ai-guide": + self.handle_ai_guide(body) + elif path == "/api/quick-add": + self.handle_quick_add(body) + elif path == "/api/quick-add/approve": + self.handle_quick_add_approve(body) + elif path == "/api/quick-add/delete": + self.handle_quick_add_delete(body) + elif path == "/api/quick-add/attach": + self.handle_quick_add_attach(body) + elif path == "/api/places/autocomplete": + self.handle_places_autocomplete(body) + elif path == "/api/immich/photos": + self.handle_immich_photos(body) + elif path == "/api/immich/download": + self.handle_immich_download(body) + elif path.startswith("/api/immich/thumbnail/"): + asset_id = path.split("/")[-1] + self.handle_immich_thumbnail(asset_id) + elif path == "/api/immich/albums": + self.handle_immich_albums() + elif path == "/api/immich/album-photos": + self.handle_immich_album_photos(body) + elif path == "/api/google-photos/picker-config": + self.handle_google_photos_picker_config() + elif path == "/api/google-photos/create-session": + self.handle_google_photos_create_session(body) + elif path == "/api/google-photos/check-session": + self.handle_google_photos_check_session(body) + elif path == "/api/google-photos/get-media-items": + self.handle_google_photos_get_media_items(body) + elif path == "/api/google-photos/download": + self.handle_google_photos_download(body) + else: + self.send_error(404) + + # ==================== Authentication ==================== + + def handle_login(self, content_length): + """Handle login form submission.""" + body = self.rfile.read(content_length).decode() + params = urllib.parse.parse_qs(body) + + username = params.get("username", [""])[0] + password = params.get("password", [""])[0] + + if username == USERNAME and password == PASSWORD: + token = create_session(username) + self.send_response(302) + self.send_header("Location", "/") + self.send_header("Set-Cookie", f"session={token}; Path=/; HttpOnly; SameSite=Lax; Secure") + self.end_headers() + else: + self.send_json({"error": "Invalid credentials"}, 401) + + def handle_oidc_callback(self): + """Handle OIDC callback with authorization code.""" + # Parse query parameters + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + + code = params.get("code", [None])[0] + state = params.get("state", [None])[0] + error = params.get("error", [None])[0] + + if error: + error_desc = params.get("error_description", ["Authentication failed"])[0] + self.send_json({"error": error_desc}) + return + + if not code: + self.send_json({"error": "No authorization code received"}) + return + + # Verify state (CSRF protection) + cookies = self.parse_cookies() + stored_state = cookies.get("oidc_state") + if not stored_state or stored_state != state: + self.send_json({"error": "Invalid state parameter"}) + return + + # Exchange code for tokens + tokens = exchange_oidc_code(code) + if not tokens: + self.send_json({"error": "Failed to exchange authorization code"}) + return + + access_token = tokens.get("access_token") + if not access_token: + self.send_json({"error": "No access token received"}) + return + + # Get user info + userinfo = get_oidc_userinfo(access_token) + if not userinfo: + self.send_json({"error": "Failed to get user info"}) + return + + # Create session with username from OIDC (email or preferred_username) + username = userinfo.get("email") or userinfo.get("preferred_username") or userinfo.get("sub") + session_token = create_session(username) + + # Get id_token for logout + id_token = tokens.get("id_token", "") + + # Clear state cookie and set session cookie + self.send_response(302) + self.send_header("Location", "/") + self.send_header("Set-Cookie", f"session={session_token}; Path=/; HttpOnly; SameSite=Lax; Secure") + self.send_header("Set-Cookie", "oidc_state=; Path=/; Max-Age=0; SameSite=Lax; Secure") + if id_token: + self.send_header("Set-Cookie", f"id_token={id_token}; Path=/; HttpOnly; SameSite=Lax; Secure") + self.end_headers() + + # ==================== Trip CRUD ==================== + + def handle_get_trips(self): + """Get all trips.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT * FROM trips ORDER BY start_date DESC") + trips = [dict_from_row(row) for row in cursor.fetchall()] + + # Include cover image for each trip + for trip in trips: + image = get_primary_image("trip", trip["id"]) + if image: + trip["cover_image"] = f'/images/{image["file_path"]}' + elif trip.get("image_path"): + trip["cover_image"] = f'/images/{trip["image_path"]}' + else: + trip["cover_image"] = None + + conn.close() + self.send_json({"trips": trips}) + + def handle_get_trip(self, trip_id): + """Get single trip with all data.""" + conn = get_db() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM trips WHERE id = ?", (trip_id,)) + trip = dict_from_row(cursor.fetchone()) + + if not trip: + conn.close() + self.send_json({"error": "Trip not found"}, 404) + return + + cursor.execute("SELECT * FROM transportations WHERE trip_id = ? ORDER BY date", (trip_id,)) + trip["transportations"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM lodging WHERE trip_id = ? ORDER BY check_in", (trip_id,)) + trip["lodging"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM notes WHERE trip_id = ? ORDER BY date", (trip_id,)) + trip["notes"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM locations WHERE trip_id = ? ORDER BY visit_date", (trip_id,)) + trip["locations"] = [dict_from_row(row) for row in cursor.fetchall()] + + # Collect all entity IDs to fetch images + entity_ids = [trip_id] # trip itself + for t in trip["transportations"]: + entity_ids.append(t["id"]) + for l in trip["lodging"]: + entity_ids.append(l["id"]) + for l in trip["locations"]: + entity_ids.append(l["id"]) + for n in trip["notes"]: + entity_ids.append(n["id"]) + + # Fetch all images for this trip's entities in one query + placeholders = ','.join('?' * len(entity_ids)) + cursor.execute(f"SELECT * FROM images WHERE entity_id IN ({placeholders}) ORDER BY is_primary DESC, created_at", entity_ids) + all_images = [dict_from_row(row) for row in cursor.fetchall()] + + # Build image lookup by entity_id + images_by_entity = {} + for img in all_images: + img["url"] = f'/images/{img["file_path"]}' + images_by_entity.setdefault(img["entity_id"], []).append(img) + + # Attach images to each entity + trip["images"] = images_by_entity.get(trip_id, []) + for t in trip["transportations"]: + t["images"] = images_by_entity.get(t["id"], []) + for l in trip["lodging"]: + l["images"] = images_by_entity.get(l["id"], []) + for l in trip["locations"]: + l["images"] = images_by_entity.get(l["id"], []) + for n in trip["notes"]: + n["images"] = images_by_entity.get(n["id"], []) + + # Hero images: trip primary + all entity images + hero_images = [] + trip_img = images_by_entity.get(trip_id, []) + if trip_img: + hero_images.extend(trip_img) + for img in all_images: + if img["entity_id"] != trip_id and img not in hero_images: + hero_images.append(img) + trip["hero_images"] = hero_images + + # Fetch all documents for this trip's entities + cursor.execute(f"SELECT * FROM documents WHERE entity_id IN ({placeholders}) ORDER BY created_at", entity_ids) + all_docs = [dict_from_row(row) for row in cursor.fetchall()] + docs_by_entity = {} + for doc in all_docs: + doc["url"] = f'/api/document/{doc["file_path"]}' + docs_by_entity.setdefault(doc["entity_id"], []).append(doc) + + for t in trip["transportations"]: + t["documents"] = docs_by_entity.get(t["id"], []) + for l in trip["lodging"]: + l["documents"] = docs_by_entity.get(l["id"], []) + for l in trip["locations"]: + l["documents"] = docs_by_entity.get(l["id"], []) + for n in trip["notes"]: + n["documents"] = docs_by_entity.get(n["id"], []) + trip["documents"] = docs_by_entity.get(trip_id, []) + + conn.close() + self.send_json(trip) + + def handle_create_trip(self, body): + """Create a new trip.""" + data = json.loads(body) + trip_id = generate_id() + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO trips (id, name, description, start_date, end_date) + VALUES (?, ?, ?, ?, ?) + ''', (trip_id, data.get("name", ""), data.get("description", ""), + data.get("start_date", ""), data.get("end_date", ""))) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": trip_id}) + + def handle_update_trip(self, body): + """Update a trip.""" + data = json.loads(body) + trip_id = data.get("id") + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + UPDATE trips SET name = ?, description = ?, start_date = ?, end_date = ?, + share_password = ?, immich_album_id = ? + WHERE id = ? + ''', (data.get("name", ""), data.get("description", ""), + data.get("start_date", ""), data.get("end_date", ""), + data.get("share_password", "") or None, + data.get("immich_album_id") or None, trip_id)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_delete_trip(self, body): + """Delete a trip and all related data.""" + data = json.loads(body) + trip_id = data.get("id") + + # Delete associated images from filesystem + conn = get_db() + cursor = conn.cursor() + + # Get all entity IDs for this trip + for table in ["transportations", "lodging", "notes", "locations"]: + cursor.execute(f"SELECT id FROM {table} WHERE trip_id = ?", (trip_id,)) + for row in cursor.fetchall(): + entity_type = table.rstrip("s") if table != "lodging" else "lodging" + cursor.execute("SELECT file_path FROM images WHERE entity_type = ? AND entity_id = ?", + (entity_type, row["id"])) + for img_row in cursor.fetchall(): + img_path = IMAGES_DIR / img_row["file_path"] + if img_path.exists(): + img_path.unlink() + + # Delete trip (cascades to related tables) + cursor.execute("DELETE FROM trips WHERE id = ?", (trip_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + # ==================== Transportation CRUD ==================== + + def handle_create_transportation(self, body): + """Create transportation.""" + data = json.loads(body) + trans_id = generate_id() + + from_loc = data.get("from_location", "") + to_loc = data.get("to_location", "") + trans_type = data.get("type", "plane") + from_place_id = data.get("from_place_id", "") + to_place_id = data.get("to_place_id", "") + from_lat = data.get("from_lat") + from_lng = data.get("from_lng") + to_lat = data.get("to_lat") + to_lng = data.get("to_lng") + + # Resolve coordinates from place_ids if not provided + if from_place_id and not (from_lat and from_lng): + details = get_place_details(from_place_id) + from_lat = details.get("latitude") or from_lat + from_lng = details.get("longitude") or from_lng + if to_place_id and not (to_lat and to_lng): + details = get_place_details(to_place_id) + to_lat = details.get("latitude") or to_lat + to_lng = details.get("longitude") or to_lng + + # Auto-resolve from/to place_ids from location names + if not from_place_id and not (from_lat and from_lng) and from_loc: + pid, plat, plng, _ = auto_resolve_place(from_loc) + if pid: + from_place_id = pid + from_lat = plat or from_lat + from_lng = plng or from_lng + if not to_place_id and not (to_lat and to_lng) and to_loc: + pid, plat, plng, _ = auto_resolve_place(to_loc) + if pid: + to_place_id = pid + to_lat = plat or to_lat + to_lng = plng or to_lng + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO transportations (id, trip_id, name, type, flight_number, from_location, + to_location, date, end_date, timezone, description, link, cost_points, cost_cash, + from_place_id, to_place_id, from_lat, from_lng, to_lat, to_lng) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (trans_id, data.get("trip_id"), data.get("name", ""), trans_type, + data.get("flight_number", ""), from_loc, to_loc, + data.get("date", ""), data.get("end_date", ""), data.get("timezone", ""), + data.get("description", ""), data.get("link", ""), + data.get("cost_points", 0), data.get("cost_cash", 0), + from_place_id, to_place_id, from_lat, from_lng, to_lat, to_lng)) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": trans_id}) + + def handle_update_transportation(self, body): + """Update transportation.""" + data = json.loads(body) + + from_loc = data.get("from_location", "") + to_loc = data.get("to_location", "") + trans_type = data.get("type", "plane") + from_place_id = data.get("from_place_id", "") + to_place_id = data.get("to_place_id", "") + from_lat = data.get("from_lat") + from_lng = data.get("from_lng") + to_lat = data.get("to_lat") + to_lng = data.get("to_lng") + + # Resolve coordinates from place_ids if not provided + if from_place_id and not (from_lat and from_lng): + details = get_place_details(from_place_id) + from_lat = details.get("latitude") or from_lat + from_lng = details.get("longitude") or from_lng + if to_place_id and not (to_lat and to_lng): + details = get_place_details(to_place_id) + to_lat = details.get("latitude") or to_lat + to_lng = details.get("longitude") or to_lng + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + UPDATE transportations SET name = ?, type = ?, flight_number = ?, from_location = ?, + to_location = ?, date = ?, end_date = ?, timezone = ?, + description = ?, link = ?, cost_points = ?, cost_cash = ?, + from_place_id = ?, to_place_id = ?, from_lat = ?, from_lng = ?, to_lat = ?, to_lng = ? + WHERE id = ? + ''', (data.get("name", ""), trans_type, data.get("flight_number", ""), + from_loc, to_loc, data.get("date", ""), + data.get("end_date", ""), data.get("timezone", ""), data.get("description", ""), + data.get("link", ""), data.get("cost_points", 0), data.get("cost_cash", 0), + from_place_id, to_place_id, from_lat, from_lng, to_lat, to_lng, + data.get("id"))) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_delete_transportation(self, body): + """Delete transportation.""" + data = json.loads(body) + entity_id = data.get("id") + + # Delete images + self._delete_entity_images("transportation", entity_id) + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM transportations WHERE id = ?", (entity_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + # ==================== Lodging CRUD ==================== + + def handle_create_lodging(self, body): + """Create lodging.""" + data = json.loads(body) + lodging_id = generate_id() + place_id = data.get("place_id") + lat = data.get("latitude") + lng = data.get("longitude") + + # Resolve coordinates from place_id if not provided + if place_id and not (lat and lng): + details = get_place_details(place_id) + lat = details.get("latitude") or lat + lng = details.get("longitude") or lng + + # Auto-resolve place_id from name if not provided + if not place_id and not (lat and lng) and data.get("name"): + pid, plat, plng, paddr = auto_resolve_place(data.get("name", ""), data.get("location", "")) + if pid: + place_id = pid + lat = plat or lat + lng = plng or lng + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO lodging (id, trip_id, name, type, location, check_in, check_out, + timezone, reservation_number, description, link, cost_points, cost_cash, place_id, latitude, longitude) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (lodging_id, data.get("trip_id"), data.get("name", ""), data.get("type", "hotel"), + data.get("location", ""), data.get("check_in", ""), data.get("check_out", ""), + data.get("timezone", ""), data.get("reservation_number", ""), + data.get("description", ""), data.get("link", ""), + data.get("cost_points", 0), data.get("cost_cash", 0), place_id, lat, lng)) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": lodging_id}) + + def handle_update_lodging(self, body): + """Update lodging.""" + data = json.loads(body) + place_id = data.get("place_id") + lat = data.get("latitude") + lng = data.get("longitude") + + # Resolve coordinates from place_id if not provided + if place_id and not (lat and lng): + details = get_place_details(place_id) + lat = details.get("latitude") or lat + lng = details.get("longitude") or lng + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + UPDATE lodging SET name = ?, type = ?, location = ?, check_in = ?, check_out = ?, + timezone = ?, reservation_number = ?, description = ?, link = ?, + cost_points = ?, cost_cash = ?, place_id = ?, latitude = ?, longitude = ? + WHERE id = ? + ''', (data.get("name", ""), data.get("type", "hotel"), data.get("location", ""), + data.get("check_in", ""), data.get("check_out", ""), data.get("timezone", ""), + data.get("reservation_number", ""), data.get("description", ""), + data.get("link", ""), data.get("cost_points", 0), data.get("cost_cash", 0), + place_id, lat, lng, data.get("id"))) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_delete_lodging(self, body): + """Delete lodging.""" + data = json.loads(body) + entity_id = data.get("id") + + self._delete_entity_images("lodging", entity_id) + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM lodging WHERE id = ?", (entity_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + # ==================== Notes CRUD ==================== + + def handle_create_note(self, body): + """Create note.""" + data = json.loads(body) + note_id = generate_id() + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO notes (id, trip_id, name, content, date) + VALUES (?, ?, ?, ?, ?) + ''', (note_id, data.get("trip_id"), data.get("name", ""), + data.get("content", ""), data.get("date", ""))) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": note_id}) + + def handle_update_note(self, body): + """Update note.""" + data = json.loads(body) + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + UPDATE notes SET name = ?, content = ?, date = ? + WHERE id = ? + ''', (data.get("name", ""), data.get("content", ""), + data.get("date", ""), data.get("id"))) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_delete_note(self, body): + """Delete note.""" + data = json.loads(body) + entity_id = data.get("id") + + self._delete_entity_images("note", entity_id) + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM notes WHERE id = ?", (entity_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + # ==================== Locations CRUD ==================== + + def handle_create_location(self, body): + """Create location.""" + data = json.loads(body) + loc_id = generate_id() + place_id = data.get("place_id") + address = data.get("address") + lat = data.get("latitude") + lng = data.get("longitude") + + # Resolve coordinates from place_id if not provided + if place_id and not (lat and lng): + details = get_place_details(place_id) + lat = details.get("latitude") or lat + lng = details.get("longitude") or lng + if not address: + address = details.get("address", "") + + # Auto-resolve place_id from name if not provided + if not place_id and not (lat and lng) and data.get("name"): + pid, plat, plng, paddr = auto_resolve_place(data.get("name", ""), address or "") + if pid: + place_id = pid + lat = plat or lat + lng = plng or lng + if not address: + address = paddr or "" + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO locations (id, trip_id, name, description, category, visit_date, start_time, end_time, link, cost_points, cost_cash, hike_distance, hike_difficulty, hike_time, place_id, address, latitude, longitude) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (loc_id, data.get("trip_id"), data.get("name", ""), data.get("description", ""), + data.get("category", ""), + data.get("visit_date", ""), data.get("start_time", ""), data.get("end_time", ""), + data.get("link", ""), data.get("cost_points", 0), data.get("cost_cash", 0), + data.get("hike_distance", ""), data.get("hike_difficulty", ""), data.get("hike_time", ""), + place_id, address, lat, lng)) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": loc_id}) + + def handle_update_location(self, body): + """Update location.""" + data = json.loads(body) + place_id = data.get("place_id") + address = data.get("address") + lat = data.get("latitude") + lng = data.get("longitude") + + # Resolve coordinates from place_id if not provided + if place_id and not (lat and lng): + details = get_place_details(place_id) + lat = details.get("latitude") or lat + lng = details.get("longitude") or lng + if not address: + address = details.get("address", "") + + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + UPDATE locations SET name = ?, description = ?, + category = ?, visit_date = ?, start_time = ?, end_time = ?, link = ?, + cost_points = ?, cost_cash = ?, hike_distance = ?, hike_difficulty = ?, hike_time = ?, + place_id = ?, address = ?, latitude = ?, longitude = ? + WHERE id = ? + ''', (data.get("name", ""), data.get("description", ""), + data.get("category", ""), + data.get("visit_date", ""), data.get("start_time", ""), data.get("end_time", ""), + data.get("link", ""), data.get("cost_points", 0), data.get("cost_cash", 0), + data.get("hike_distance", ""), data.get("hike_difficulty", ""), data.get("hike_time", ""), + place_id, address, lat, lng, + data.get("id"))) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_delete_location(self, body): + """Delete location.""" + data = json.loads(body) + entity_id = data.get("id") + + self._delete_entity_images("location", entity_id) + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM locations WHERE id = ?", (entity_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_move_item(self, body): + """Move an item to a different date (drag and drop).""" + data = json.loads(body) + item_type = data.get("type") # transportation, lodging, note, location + item_id = data.get("id") + new_date = data.get("new_date") # YYYY-MM-DD format + + if not all([item_type, item_id, new_date]): + self.send_json({"error": "Missing required fields"}, 400) + return + + conn = get_db() + cursor = conn.cursor() + + if item_type == "transportation": + # Get current datetime, update date portion + cursor.execute("SELECT date, end_date FROM transportations WHERE id = ?", (item_id,)) + row = cursor.fetchone() + if row: + old_date = row["date"] or "" + old_end = row["end_date"] or "" + # Replace date portion, keep time + new_datetime = new_date + old_date[10:] if len(old_date) > 10 else new_date + new_end = new_date + old_end[10:] if len(old_end) > 10 else new_date + cursor.execute("UPDATE transportations SET date = ?, end_date = ? WHERE id = ?", + (new_datetime, new_end, item_id)) + + elif item_type == "lodging": + # Get current datetime, update date portion + cursor.execute("SELECT check_in, check_out FROM lodging WHERE id = ?", (item_id,)) + row = cursor.fetchone() + if row: + old_checkin = row["check_in"] or "" + old_checkout = row["check_out"] or "" + new_checkin = new_date + old_checkin[10:] if len(old_checkin) > 10 else new_date + # Calculate checkout offset if exists + if old_checkin and old_checkout and len(old_checkin) >= 10 and len(old_checkout) >= 10: + try: + from datetime import datetime, timedelta + old_in_date = datetime.strptime(old_checkin[:10], "%Y-%m-%d") + old_out_date = datetime.strptime(old_checkout[:10], "%Y-%m-%d") + delta = old_out_date - old_in_date + new_in_date = datetime.strptime(new_date, "%Y-%m-%d") + new_out_date = new_in_date + delta + new_checkout = new_out_date.strftime("%Y-%m-%d") + old_checkout[10:] + except: + new_checkout = new_date + old_checkout[10:] if len(old_checkout) > 10 else new_date + else: + new_checkout = new_date + old_checkout[10:] if len(old_checkout) > 10 else new_date + cursor.execute("UPDATE lodging SET check_in = ?, check_out = ? WHERE id = ?", + (new_checkin, new_checkout, item_id)) + + elif item_type == "note": + cursor.execute("UPDATE notes SET date = ? WHERE id = ?", (new_date, item_id)) + + elif item_type == "location": + # Update visit_date AND start_time/end_time + cursor.execute("SELECT start_time, end_time FROM locations WHERE id = ?", (item_id,)) + row = cursor.fetchone() + if row: + old_start = row["start_time"] or "" + old_end = row["end_time"] or "" + new_start = new_date + old_start[10:] if len(old_start) > 10 else new_date + new_end = new_date + old_end[10:] if len(old_end) > 10 else new_date + cursor.execute("UPDATE locations SET visit_date = ?, start_time = ?, end_time = ? WHERE id = ?", + (new_date, new_start, new_end, item_id)) + else: + cursor.execute("UPDATE locations SET visit_date = ? WHERE id = ?", (new_date, item_id)) + + else: + conn.close() + self.send_json({"error": "Invalid item type"}, 400) + return + + conn.commit() + conn.close() + + # Also update in AdventureLog if possible + try: + adventurelog_endpoints = { + "transportation": f"{ADVENTURELOG_URL}/api/transportations/{item_id}/", + "lodging": f"{ADVENTURELOG_URL}/api/lodging/{item_id}/", + "note": f"{ADVENTURELOG_URL}/api/notes/{item_id}/", + "location": f"{ADVENTURELOG_URL}/api/adventures/{item_id}/" + } + + adventurelog_fields = { + "transportation": "date", + "lodging": "check_in", + "note": "date", + "location": "date" + } + + url = adventurelog_endpoints.get(item_type) + field = adventurelog_fields.get(item_type) + + if url and field: + req = urllib.request.Request(url, method='PATCH') + req.add_header('Content-Type', 'application/json') + req.add_header('Cookie', f'sessionid={ADVENTURELOG_SESSION}') + req.data = json.dumps({field: new_date}).encode() + urllib.request.urlopen(req, timeout=10) + except Exception as e: + print(f"[MOVE] Could not update AdventureLog: {e}") + + self.send_json({"success": True}) + + # ==================== Image Handling ==================== + + def _delete_entity_images(self, entity_type, entity_id): + """Delete all images for an entity.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM images WHERE entity_type = ? AND entity_id = ?", + (entity_type, entity_id)) + for row in cursor.fetchall(): + img_path = IMAGES_DIR / row["file_path"] + if img_path.exists(): + img_path.unlink() + cursor.execute("DELETE FROM images WHERE entity_type = ? AND entity_id = ?", + (entity_type, entity_id)) + conn.commit() + conn.close() + + def handle_image_upload(self, body): + """Handle image upload (base64 JSON or multipart form-data).""" + content_type = self.headers.get("Content-Type", "") + + # Handle multipart file upload + if "multipart/form-data" in content_type: + boundary = content_type.split("boundary=")[-1].encode() + parts = body.split(b"--" + boundary) + + entity_type = "" + entity_id = "" + file_data = None + file_name = "image.jpg" + + for part in parts: + if b'name="entity_type"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + entity_type = part[data_start:data_end].decode() + elif b'name="entity_id"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + entity_id = part[data_start:data_end].decode() + elif b'name="file"' in part: + if b'filename="' in part: + fn_start = part.find(b'filename="') + 10 + fn_end = part.find(b'"', fn_start) + file_name = part[fn_start:fn_end].decode() + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + file_data = part[data_start:data_end] + + if not all([entity_type, entity_id, file_data]): + self.send_json({"error": "Missing required fields (entity_type, entity_id, file)"}, 400) + return + + image_bytes = file_data + + else: + # Handle JSON with base64 image + data = json.loads(body) + entity_type = data.get("entity_type") + entity_id = data.get("entity_id") + image_data = data.get("image_data") # base64 + file_name = data.get("filename", "image.jpg") + + if not all([entity_type, entity_id, image_data]): + self.send_json({"error": "Missing required fields"}, 400) + return + + import base64 + try: + image_bytes = base64.b64decode(image_data.split(",")[-1]) + except: + self.send_json({"error": "Invalid image data"}, 400) + return + + # Save file + ext = Path(file_name).suffix or ".jpg" + file_id = generate_id() + saved_name = f"{file_id}{ext}" + file_path = IMAGES_DIR / saved_name + + with open(file_path, "wb") as f: + f.write(image_bytes) + + # Save to database + image_id = generate_id() + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO images (id, entity_type, entity_id, file_path, is_primary) + VALUES (?, ?, ?, ?, ?) + ''', (image_id, entity_type, entity_id, saved_name, 0)) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": image_id, "path": f"/images/{saved_name}"}) + + def handle_image_delete(self, body): + """Delete an image.""" + data = json.loads(body) + image_id = data.get("id") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM images WHERE id = ?", (image_id,)) + row = cursor.fetchone() + + if row: + img_path = IMAGES_DIR / row["file_path"] + if img_path.exists(): + img_path.unlink() + cursor.execute("DELETE FROM images WHERE id = ?", (image_id,)) + conn.commit() + + conn.close() + self.send_json({"success": True}) + + def handle_image_search(self, body): + """Search Google Images.""" + if not GOOGLE_API_KEY or not GOOGLE_CX: + self.send_json({"error": "Google API not configured"}, 400) + return + + data = json.loads(body) + query = data.get("query", "") + + url = f"https://www.googleapis.com/customsearch/v1?key={GOOGLE_API_KEY}&cx={GOOGLE_CX}&searchType=image&q={urllib.parse.quote(query)}&num=10" + + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + + images = [] + for item in result.get("items", []): + images.append({ + "url": item.get("link"), + "thumbnail": item.get("image", {}).get("thumbnailLink"), + "title": item.get("title") + }) + + self.send_json({"images": images}) + except Exception as e: + self.send_json({"error": str(e)}, 500) + + def handle_image_upload_from_url(self, body): + """Download image from URL and save.""" + data = json.loads(body) + entity_type = data.get("entity_type") + entity_id = data.get("entity_id") + image_url = data.get("url") + + if not all([entity_type, entity_id, image_url]): + self.send_json({"error": "Missing required fields"}, 400) + return + + try: + req = urllib.request.Request(image_url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=15) as response: + image_bytes = response.read() + + # Determine extension + content_type = response.headers.get("Content-Type", "image/jpeg") + ext_map = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"} + ext = ext_map.get(content_type.split(";")[0], ".jpg") + + # Save file + file_id = generate_id() + file_name = f"{file_id}{ext}" + file_path = IMAGES_DIR / file_name + + with open(file_path, "wb") as f: + f.write(image_bytes) + + # Save to database + image_id = generate_id() + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO images (id, entity_type, entity_id, file_path, is_primary) + VALUES (?, ?, ?, ?, ?) + ''', (image_id, entity_type, entity_id, file_name, 0)) + conn.commit() + conn.close() + + self.send_json({"success": True, "id": image_id, "path": f"/images/{file_name}"}) + except Exception as e: + self.send_json({"error": str(e)}, 500) + + # ==================== Share Handling ==================== + + def handle_create_share(self, body): + """Create a share link for a trip.""" + data = json.loads(body) + trip_id = data.get("trip_id") + + share_token = secrets.token_urlsafe(16) + + conn = get_db() + cursor = conn.cursor() + cursor.execute("UPDATE trips SET share_token = ? WHERE id = ?", (share_token, trip_id)) + conn.commit() + conn.close() + + self.send_json({"success": True, "share_token": share_token}) + + def handle_delete_share(self, body): + """Remove share link from a trip.""" + data = json.loads(body) + trip_id = data.get("trip_id") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("UPDATE trips SET share_token = NULL WHERE id = ?", (trip_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_share_verify(self, body): + """Verify password for shared trip.""" + try: + print(f"[Share] Handler called with body: {body[:100]}", flush=True) + data = json.loads(body) + share_token = data.get("share_token") + password = data.get("password", "") + + print(f"[Share] Verifying token: {share_token}", flush=True) + print(f"[Share] Password entered: {repr(password)}", flush=True) + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT share_password FROM trips WHERE share_token = ?", (share_token,)) + row = cursor.fetchone() + conn.close() + + if not row: + print(f"[Share] Trip not found for token", flush=True) + self.send_json({"success": False, "error": "Trip not found"}) + return + + stored_password = row[0] or "" + print(f"[Share] Password stored: {repr(stored_password)}", flush=True) + print(f"[Share] Match: {password == stored_password}", flush=True) + + if password == stored_password: + self.send_json({"success": True}) + else: + self.send_json({"success": False, "error": "Incorrect password"}) + except Exception as e: + print(f"[Share] ERROR: {e}", flush=True) + import traceback + traceback.print_exc() + self.send_json({"success": False, "error": str(e)}) + + # ==================== AI Parsing ==================== + + def handle_parse(self, body): + """Parse text/image/PDF with AI.""" + print(f"[PARSE] Starting parse, body size: {len(body)} bytes", flush=True) + content_type = self.headers.get("Content-Type", "") + print(f"[PARSE] Content-Type: {content_type}", flush=True) + + if "multipart/form-data" in content_type: + # Handle file upload + boundary = content_type.split("boundary=")[-1].encode() + parts = body.split(b"--" + boundary) + + text_input = "" + file_data = None + file_name = "" + mime_type = "" + trip_start = None + trip_end = None + + for part in parts: + if b'name="text"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + text_input = part[data_start:data_end].decode() + elif b'name="file"' in part: + # Extract filename + if b'filename="' in part: + fn_start = part.find(b'filename="') + 10 + fn_end = part.find(b'"', fn_start) + file_name = part[fn_start:fn_end].decode() + # Extract content type + if b"Content-Type:" in part: + ct_start = part.find(b"Content-Type:") + 14 + ct_end = part.find(b"\r\n", ct_start) + mime_type = part[ct_start:ct_end].decode().strip() + # Extract file data + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + if data_end > data_start: + file_data = part[data_start:data_end] + else: + file_data = part[data_start:] + elif b'name="trip_start"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + trip_start = part[data_start:data_end].decode().strip() + elif b'name="trip_end"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + trip_end = part[data_start:data_end].decode().strip() + + result = None + + if file_data and len(file_data) > 0: + print(f"[PARSE] File data size: {len(file_data)} bytes, name: {file_name}, mime: {mime_type}", flush=True) + file_base64 = base64.b64encode(file_data).decode() + print(f"[PARSE] Base64 size: {len(file_base64)} chars", flush=True) + + # Check if it's a PDF - send directly to OpenAI Vision + if mime_type == "application/pdf" or file_name.lower().endswith(".pdf"): + print(f"[PARSE] Calling parse_pdf_input...", flush=True) + result = parse_pdf_input(file_base64, file_name, trip_start, trip_end) + print(f"[PARSE] PDF result: {str(result)[:200]}", flush=True) + else: + # It's an image + print(f"[PARSE] Calling parse_image_input...", flush=True) + result = parse_image_input(file_base64, mime_type, trip_start, trip_end) + + # Include file data for document attachment if parsing succeeded + if result and "error" not in result: + result["attachment"] = { + "data": base64.b64encode(file_data).decode(), + "name": file_name, + "mime_type": mime_type + } + elif text_input: + result = parse_text_input(text_input, trip_start, trip_end) + else: + result = {"error": "No input provided"} + + self.send_json(result) + else: + # JSON request (text only) + data = json.loads(body) + text = data.get("text", "") + trip_start = data.get("trip_start") + trip_end = data.get("trip_end") + + if text: + result = parse_text_input(text, trip_start, trip_end) + self.send_json(result) + else: + self.send_json({"error": "No text provided"}) + + def handle_parse_email(self, body): + """Parse email content sent from Cloudflare Email Worker.""" + # Verify API key + api_key = self.headers.get("X-API-Key", "") + if not EMAIL_API_KEY: + self.send_json({"error": "Email API not configured"}, 503) + return + if api_key != EMAIL_API_KEY: + self.send_json({"error": "Invalid API key"}, 401) + return + + print("[EMAIL] Received email for parsing", flush=True) + content_type = self.headers.get("Content-Type", "") + text_input = "" # Initialize for email metadata extraction + + try: + if "multipart/form-data" in content_type: + # Handle with file attachment + boundary = content_type.split("boundary=")[-1].encode() + parts = body.split(b"--" + boundary) + + text_input = "" + file_data = None + file_name = "" + mime_type = "" + + for part in parts: + if b'name="text"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + text_input = part[data_start:data_end].decode() + elif b'name="file"' in part: + if b'filename="' in part: + fn_start = part.find(b'filename="') + 10 + fn_end = part.find(b'"', fn_start) + file_name = part[fn_start:fn_end].decode() + if b"Content-Type:" in part: + ct_start = part.find(b"Content-Type:") + 14 + ct_end = part.find(b"\r\n", ct_start) + mime_type = part[ct_start:ct_end].decode().strip() + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + file_data = part[data_start:data_end] if data_end > data_start else part[data_start:] + + # Parse based on content + if file_data: + if mime_type.startswith("image/"): + image_b64 = base64.b64encode(file_data).decode() + result = parse_image_input(image_b64, mime_type) + elif mime_type == "application/pdf": + result = parse_pdf_input(file_data) + else: + result = parse_text_input(text_input) if text_input else {"error": "Unsupported file type"} + elif text_input: + result = parse_text_input(text_input) + else: + result = {"error": "No content provided"} + else: + # JSON request (text only) + data = json.loads(body) + text = data.get("text", "") + text_input = text # For email metadata extraction + if text: + result = parse_text_input(text) + else: + result = {"error": "No text provided"} + + # Save to pending imports if successfully parsed + if result and "error" not in result: + entry_type = result.get("type", "unknown") + + # Extract email metadata from text content + email_subject = "" + email_from = "" + if text_input: + for line in text_input.split('\n'): + if line.startswith('Subject:'): + email_subject = line[8:].strip()[:200] + elif line.startswith('From:'): + email_from = line[5:].strip()[:200] + + # Save to pending_imports table + import_id = str(uuid.uuid4()) + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO pending_imports (id, entry_type, parsed_data, source, email_subject, email_from) + VALUES (?, ?, ?, ?, ?, ?) + ''', (import_id, entry_type, json.dumps(result), 'email', email_subject, email_from)) + conn.commit() + conn.close() + + print(f"[EMAIL] Saved to pending imports: {import_id} ({entry_type})", flush=True) + result["import_id"] = import_id + + self.send_json(result) + + except Exception as e: + print(f"[EMAIL] Error parsing: {e}", flush=True) + self.send_json({"error": str(e)}, 500) + + # ==================== Document Management ==================== + + def handle_document_upload(self, body): + """Upload a document to an entity.""" + content_type = self.headers.get("Content-Type", "") + + if "multipart/form-data" not in content_type: + self.send_json({"error": "Must be multipart/form-data"}, 400) + return + + boundary = content_type.split("boundary=")[-1].encode() + parts = body.split(b"--" + boundary) + + entity_type = "" + entity_id = "" + file_data = None + file_name = "" + mime_type = "" + + for part in parts: + if b'name="entity_type"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + entity_type = part[data_start:data_end].decode() + elif b'name="entity_id"' in part: + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + entity_id = part[data_start:data_end].decode() + elif b'name="file"' in part: + # Extract filename + if b'filename="' in part: + fn_start = part.find(b'filename="') + 10 + fn_end = part.find(b'"', fn_start) + file_name = part[fn_start:fn_end].decode() + # Extract content type + if b"Content-Type:" in part: + ct_start = part.find(b"Content-Type:") + 14 + ct_end = part.find(b"\r\n", ct_start) + mime_type = part[ct_start:ct_end].decode().strip() + # Extract file data + data_start = part.find(b"\r\n\r\n") + 4 + data_end = part.rfind(b"\r\n") + if data_end > data_start: + file_data = part[data_start:data_end] + else: + file_data = part[data_start:] + + if not all([entity_type, entity_id, file_data, file_name]): + self.send_json({"error": "Missing required fields"}, 400) + return + + # Save file + doc_id = generate_id() + ext = file_name.rsplit(".", 1)[-1] if "." in file_name else "dat" + stored_name = f"{doc_id}.{ext}" + file_path = DOCS_DIR / stored_name + + with open(file_path, "wb") as f: + f.write(file_data) + + # Save to database + conn = get_db() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO documents (id, entity_type, entity_id, file_path, file_name, mime_type) VALUES (?, ?, ?, ?, ?, ?)", + (doc_id, entity_type, entity_id, stored_name, file_name, mime_type) + ) + conn.commit() + conn.close() + + self.send_json({ + "success": True, + "document_id": doc_id, + "file_name": file_name, + "url": f"/documents/{stored_name}" + }) + + def handle_document_delete(self, body): + """Delete a document.""" + data = json.loads(body) + doc_id = data.get("document_id") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM documents WHERE id = ?", (doc_id,)) + row = cursor.fetchone() + + if row: + file_path = DOCS_DIR / row["file_path"] + if file_path.exists(): + file_path.unlink() + + cursor.execute("DELETE FROM documents WHERE id = ?", (doc_id,)) + conn.commit() + + conn.close() + self.send_json({"success": True}) + + def handle_check_duplicate(self, body): + """Check for duplicate flights or hotels.""" + data = json.loads(body) + trip_id = data.get("trip_id") + entry_type = data.get("type", "flight") + + duplicate = None + existing_data = None + + if entry_type == "flight": + flight_number = data.get("flight_number") + flight_date = data.get("date") + duplicate = find_duplicate_flight(trip_id, flight_number, flight_date) + if duplicate: + existing_data = { + "id": duplicate.get("id"), + "type": "flight", + "name": duplicate.get("name"), + "flight_number": duplicate.get("flight_number"), + "from_location": duplicate.get("from_location"), + "to_location": duplicate.get("to_location"), + "date": duplicate.get("date"), + "description": duplicate.get("description", "") + } + elif entry_type == "hotel": + hotel_name = data.get("hotel_name") + check_in = data.get("check_in") + reservation_number = data.get("reservation_number") + duplicate = find_duplicate_hotel(trip_id, hotel_name, check_in, reservation_number) + if duplicate: + existing_data = { + "id": duplicate.get("id"), + "type": "hotel", + "name": duplicate.get("name"), + "location": duplicate.get("location"), + "check_in": duplicate.get("check_in"), + "check_out": duplicate.get("check_out"), + "reservation_number": duplicate.get("reservation_number"), + "description": duplicate.get("description", "") + } + + if duplicate: + self.send_json({"found": True, "existing": existing_data}) + else: + self.send_json({"found": False}) + + def handle_merge_entry(self, body): + """Merge new booking info into existing entry.""" + data = json.loads(body) + entry_type = data.get("entry_type", "flight") + entry_id = data.get("entry_id") + existing_description = data.get("existing_description", "") + new_info = data.get("new_info", {}) + new_data = data.get("new_data", {}) # Full data from email imports + attachment_data = data.get("attachment") + + def merge_field(existing, new): + """Merge two field values - fill empty or append if different.""" + if not existing: + return new or "" + if not new: + return existing + if existing.strip() == new.strip(): + return existing + # Append new info if different + return f"{existing}\n{new}" + + try: + conn = get_db() + cursor = conn.cursor() + + if entry_type == "flight": + entity_type = "transportation" + + # Get existing entry + cursor.execute("SELECT * FROM transportations WHERE id = ?", (entry_id,)) + existing = dict_from_row(cursor.fetchone()) + if not existing: + conn.close() + self.send_json({"error": "Entry not found"}) + return + + # Handle doc upload format (new_info with passengers) + new_description = existing.get("description", "") + passengers = new_info.get("passengers", []) + for p in passengers: + conf = p.get("confirmation", "") + if conf and conf not in new_description: + new_description = merge_field(new_description, conf) + + # Handle email import format (new_data with full fields) + if new_data: + new_description = merge_field(new_description, new_data.get("description", "")) + # Fill in empty fields only + updates = [] + params = [] + if not existing.get("from_location") and new_data.get("from_location"): + updates.append("from_location = ?") + params.append(new_data["from_location"]) + if not existing.get("to_location") and new_data.get("to_location"): + updates.append("to_location = ?") + params.append(new_data["to_location"]) + if not existing.get("date") and new_data.get("date"): + updates.append("date = ?") + params.append(new_data["date"]) + if not existing.get("end_date") and new_data.get("end_date"): + updates.append("end_date = ?") + params.append(new_data["end_date"]) + + updates.append("description = ?") + params.append(new_description) + params.append(entry_id) + + cursor.execute(f"UPDATE transportations SET {', '.join(updates)} WHERE id = ?", params) + else: + cursor.execute( + "UPDATE transportations SET description = ? WHERE id = ?", + (new_description, entry_id) + ) + + elif entry_type == "hotel": + entity_type = "lodging" + + # Get existing entry + cursor.execute("SELECT * FROM lodging WHERE id = ?", (entry_id,)) + existing = dict_from_row(cursor.fetchone()) + if not existing: + conn.close() + self.send_json({"error": "Entry not found"}) + return + + # Handle doc upload format (new_info with reservation_number) + new_description = existing.get("description", "") + reservation_number = new_info.get("reservation_number", "") + if reservation_number and reservation_number not in new_description: + new_description = merge_field(new_description, f"Reservation: {reservation_number}") + + # Handle email import format (new_data with full fields) + if new_data: + new_description = merge_field(new_description, new_data.get("description", "")) + # Add reservation number if not already present + new_res = new_data.get("reservation_number", "") + if new_res and new_res not in new_description: + new_description = merge_field(new_description, f"Reservation: {new_res}") + + # Fill in empty fields only + updates = [] + params = [] + if not existing.get("location") and new_data.get("location"): + updates.append("location = ?") + params.append(new_data["location"]) + if not existing.get("check_in") and new_data.get("check_in"): + updates.append("check_in = ?") + params.append(new_data["check_in"]) + if not existing.get("check_out") and new_data.get("check_out"): + updates.append("check_out = ?") + params.append(new_data["check_out"]) + if not existing.get("reservation_number") and new_data.get("reservation_number"): + updates.append("reservation_number = ?") + params.append(new_data["reservation_number"]) + + updates.append("description = ?") + params.append(new_description) + params.append(entry_id) + + cursor.execute(f"UPDATE lodging SET {', '.join(updates)} WHERE id = ?", params) + else: + cursor.execute( + "UPDATE lodging SET description = ? WHERE id = ?", + (new_description, entry_id) + ) + else: + conn.close() + self.send_json({"error": f"Unknown entry type: {entry_type}"}) + return + + conn.commit() + + # Upload attachment if provided + attachment_result = None + if attachment_data and attachment_data.get("data"): + file_data = base64.b64decode(attachment_data["data"]) + file_name = attachment_data.get("name", "attachment") + mime_type = attachment_data.get("mime_type", "application/octet-stream") + + doc_id = generate_id() + ext = file_name.rsplit(".", 1)[-1] if "." in file_name else "dat" + stored_name = f"{doc_id}.{ext}" + file_path = DOCS_DIR / stored_name + + with open(file_path, "wb") as f: + f.write(file_data) + + cursor.execute( + "INSERT INTO documents (id, entity_type, entity_id, file_path, file_name, mime_type) VALUES (?, ?, ?, ?, ?, ?)", + (doc_id, entity_type, entry_id, stored_name, file_name, mime_type) + ) + conn.commit() + attachment_result = {"id": doc_id, "file_name": file_name} + + conn.close() + + response_data = {"success": True, "entry_id": entry_id} + if attachment_result: + response_data["attachment"] = attachment_result + + self.send_json(response_data) + + except Exception as e: + self.send_json({"error": str(e)}) + + def handle_flight_status(self, body): + """Get live flight status from FlightStats.""" + data = json.loads(body) + flight_number = data.get("flight_number", "") + date_str = data.get("date") # Optional: YYYY-MM-DD + + if not flight_number: + self.send_json({"error": "Flight number required"}) + return + + # Parse flight number into airline code and number + airline_code, flight_num = parse_flight_number(flight_number) + + if not airline_code or not flight_num: + self.send_json({"error": f"Could not parse flight number: {flight_number}. Expected format like 'SV20' or 'AA1234'"}) + return + + # Fetch status + result = get_flight_status(airline_code, flight_num, date_str) + self.send_json(result) + + def handle_weather(self, body): + """Get weather forecast for locations and dates. + + Accepts either: + - trip_id + dates: Will compute location for each date based on lodging/transportation + - location + dates: Uses single location for all dates (legacy) + """ + data = json.loads(body) + trip_id = data.get("trip_id", "") + location = data.get("location", "") + dates = data.get("dates", []) # List of YYYY-MM-DD strings + + if not dates: + self.send_json({"error": "Dates required"}) + return + + # If trip_id provided, compute location(s) per date + if trip_id: + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT * FROM trips WHERE id = ?", (trip_id,)) + trip = dict_from_row(cursor.fetchone()) + + if not trip: + conn.close() + self.send_json({"error": "Trip not found"}) + return + + # Load lodging and transportations for location lookup + cursor.execute("SELECT * FROM lodging WHERE trip_id = ? ORDER BY check_in", (trip_id,)) + trip["lodging"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM transportations WHERE trip_id = ? ORDER BY date", (trip_id,)) + trip["transportations"] = [dict_from_row(row) for row in cursor.fetchall()] + conn.close() + + # Get locations for each date (may have multiple for travel days) + date_locations = {} # {date: [{location, city, type}, ...]} + all_unique_locations = set() + + for date_str in dates: + locs = get_locations_for_date(trip, date_str) + date_locations[date_str] = locs + for loc_info in locs: + all_unique_locations.add(loc_info["location"]) + + # Fetch weather for each unique location (in parallel) + location_weather = {} # {location: {date: weather_data}} + from concurrent.futures import ThreadPoolExecutor, as_completed + + def fetch_loc_weather(loc): + return loc, get_weather_forecast(loc, dates) + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = {executor.submit(fetch_loc_weather, loc): loc for loc in all_unique_locations} + for future in as_completed(futures): + try: + loc, result = future.result() + if result.get("forecasts"): + location_weather[loc] = result["forecasts"] + except Exception as e: + print(f"[Weather] Error fetching {futures[future]}: {e}", flush=True) + + # Build response with weather per date, including city names + forecasts = {} + for date_str, locs in date_locations.items(): + date_weather = [] + for loc_info in locs: + loc = loc_info["location"] + weather = location_weather.get(loc, {}).get(date_str) + if weather: + date_weather.append({ + "city": loc_info["city"], + "type": loc_info["type"], + "icon": weather.get("icon", ""), + "description": weather.get("description", ""), + "high": weather.get("high"), + "low": weather.get("low") + }) + if date_weather: + forecasts[date_str] = date_weather + + self.send_json({"forecasts": forecasts}) + return + + # Legacy: single location for all dates + if not location: + self.send_json({"error": "Location or trip_id required"}) + return + + result = get_weather_forecast(location, dates) + self.send_json(result) + + def handle_trail_info(self, body): + """Fetch trail info using GPT.""" + data = json.loads(body) + query = data.get("query", "").strip() + hints = data.get("hints", "").strip() + + if not query: + self.send_json({"error": "Trail name or URL required"}) + return + + result = fetch_trail_info(query, hints) + self.send_json(result) + + def handle_generate_description(self, body): + """Generate a description for an attraction using GPT.""" + data = json.loads(body) + name = data.get("name", "").strip() + category = data.get("category", "attraction").strip() + location = data.get("location", "").strip() + + if not name: + self.send_json({"error": "Attraction name required"}) + return + + result = generate_attraction_description(name, category, location) + self.send_json(result) + + def handle_geocode_all(self): + """Backfill place_ids for all locations, lodging, and transportation that don't have them.""" + if not self.is_authenticated(): + self.send_json({"error": "Unauthorized"}, 401) + return + + def lookup_place_id(query): + """Look up place_id using Google Places API.""" + if not GOOGLE_API_KEY or not query: + return None, None + try: + url = "https://places.googleapis.com/v1/places:autocomplete" + headers = { + "Content-Type": "application/json", + "X-Goog-Api-Key": GOOGLE_API_KEY + } + payload = json.dumps({"input": query, "languageCode": "en"}).encode() + req = urllib.request.Request(url, data=payload, headers=headers, method="POST") + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(req, context=ssl_context, timeout=10) as response: + data = json.loads(response.read().decode()) + if data.get("suggestions"): + first = data["suggestions"][0].get("placePrediction", {}) + place_id = first.get("placeId") + address = first.get("structuredFormat", {}).get("secondaryText", {}).get("text", "") + return place_id, address + except Exception as e: + print(f"Place lookup error for '{query}': {e}") + return None, None + + conn = get_db() + cursor = conn.cursor() + updated = {"locations": 0, "lodging": 0, "transportations": 0} + + # Backfill locations + cursor.execute("SELECT id, name FROM locations WHERE (place_id IS NULL OR place_id = '' OR latitude IS NULL) AND name != ''") + for row in cursor.fetchall(): + place_id, _ = lookup_place_id(row[1]) + if place_id: + details = get_place_details(place_id) + lat = details.get("latitude") + lng = details.get("longitude") + address = details.get("address", "") + if lat and lng: + cursor.execute("UPDATE locations SET place_id = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?", + (place_id, address, lat, lng, row[0])) + else: + cursor.execute("UPDATE locations SET place_id = ?, address = ? WHERE id = ?", (place_id, address, row[0])) + updated["locations"] += 1 + print(f" Location: {row[1]} -> {place_id} ({lat}, {lng})") + + # Backfill lodging + cursor.execute("SELECT id, name, location FROM lodging WHERE (place_id IS NULL OR place_id = '' OR latitude IS NULL)") + for row in cursor.fetchall(): + query = f"{row[1] or ''} {row[2] or ''}".strip() + if query: + place_id, _ = lookup_place_id(query) + if place_id: + details = get_place_details(place_id) + lat = details.get("latitude") + lng = details.get("longitude") + if lat and lng: + cursor.execute("UPDATE lodging SET place_id = ?, latitude = ?, longitude = ? WHERE id = ?", + (place_id, lat, lng, row[0])) + else: + cursor.execute("UPDATE lodging SET place_id = ? WHERE id = ?", (place_id, row[0])) + updated["lodging"] += 1 + print(f" Lodging: {query} -> {place_id} ({lat}, {lng})") + + # Backfill transportation + cursor.execute("SELECT id, type, from_location, to_location, from_place_id, to_place_id FROM transportations") + for row in cursor.fetchall(): + trans_id, trans_type, from_loc, to_loc, from_pid, to_pid = row + updated_trans = False + if from_loc and not from_pid: + query = format_location_for_geocoding(from_loc, is_plane=(trans_type == "plane")) + place_id, _ = lookup_place_id(query) + if place_id: + cursor.execute("UPDATE transportations SET from_place_id = ? WHERE id = ?", (place_id, trans_id)) + updated_trans = True + print(f" Transport from: {from_loc} -> {place_id}") + if to_loc and not to_pid: + query = format_location_for_geocoding(to_loc, is_plane=(trans_type == "plane")) + place_id, _ = lookup_place_id(query) + if place_id: + cursor.execute("UPDATE transportations SET to_place_id = ? WHERE id = ?", (place_id, trans_id)) + updated_trans = True + print(f" Transport to: {to_loc} -> {place_id}") + if updated_trans: + updated["transportations"] += 1 + + conn.commit() + conn.close() + + self.send_json({"success": True, "updated": updated}) + + def handle_ai_guide(self, body): + """Generate AI tour guide suggestions using Gemini or OpenAI.""" + data = json.loads(body) + trip_id = data.get("trip_id") + provider = data.get("provider", "gemini") # "gemini" or "openai" + + if not trip_id: + self.send_json({"error": "trip_id required"}, 400) + return + + if provider == "gemini" and not GEMINI_API_KEY: + self.send_json({"error": "Gemini API key not configured"}, 500) + return + + if provider == "openai" and not OPENAI_API_KEY: + self.send_json({"error": "OpenAI API key not configured"}, 500) + return + + # Get trip data + conn = get_db() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM trips WHERE id = ?", (trip_id,)) + trip_row = cursor.fetchone() + if not trip_row: + conn.close() + self.send_json({"error": "Trip not found"}, 404) + return + + trip = dict(trip_row) + + # Get all trip items + cursor.execute("SELECT * FROM transportations WHERE trip_id = ?", (trip_id,)) + transportations = [dict(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM lodging WHERE trip_id = ?", (trip_id,)) + lodging = [dict(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM locations WHERE trip_id = ?", (trip_id,)) + locations = [dict(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM notes WHERE trip_id = ?", (trip_id,)) + notes = [dict(row) for row in cursor.fetchall()] + + conn.close() + + # Build trip summary for AI + trip_summary = f""" +TRIP: {trip.get('name', 'Unnamed Trip')} +DATES: {trip.get('start_date', 'Unknown')} to {trip.get('end_date', 'Unknown')} +DESCRIPTION: {trip.get('description', 'No description')} + +TRANSPORTATION ({len(transportations)} items): +""" + for t in transportations: + trip_summary += f"- {t.get('type', 'transport')}: {t.get('name', '')} from {t.get('from_location', '?')} to {t.get('to_location', '?')} on {t.get('date', '?')}\n" + + trip_summary += f"\nLODGING ({len(lodging)} items):\n" + for l in lodging: + trip_summary += f"- {l.get('type', 'hotel')}: {l.get('name', '')} at {l.get('location', '?')}, check-in {l.get('check_in', '?')}, check-out {l.get('check_out', '?')}\n" + + trip_summary += f"\nPLANNED ACTIVITIES/LOCATIONS ({len(locations)} items):\n" + for loc in locations: + trip_summary += f"- {loc.get('category', 'activity')}: {loc.get('name', '')} on {loc.get('visit_date', loc.get('start_time', '?'))}\n" + + if notes: + trip_summary += f"\nNOTES ({len(notes)} items):\n" + for n in notes: + trip_summary += f"- {n.get('name', '')}: {n.get('content', '')[:100]}...\n" + + # Build the prompt + prompt = f"""You are an elite AI travel agent with deep local knowledge of every destination. + +You act as a trip curator who elevates journeys—not just plans them. You make confident recommendations, knowing when to suggest more and when to hold back. + +Your goal is to make each trip as memorable as possible without overwhelming the traveler. + +You think like a well-traveled friend who knows every city inside out and genuinely wants the traveler to have an incredible experience. + +You analyze the full itinerary holistically, including dates, destinations, flights, hotels, transport, transit days, planned activities, pace, and open time. + +You understand travel rhythm, including arrival fatigue, recovery needs, consecutive high-effort days, and natural rest opportunities. + +For each destination, you: +• Identify experiences that would be genuinely regrettable to miss +• Enhance what's already planned with better timing, nearby additions, or meaningful upgrades +• Spot scheduling issues, inefficiencies, or unrealistic pacing +• Adapt recommendations to the trip type (religious, adventure, family, solo, relaxation) +• Consider seasonality, weather, and relevant local events + +You identify gaps and missed opportunities, but only suggest changes when they clearly improve flow, cultural depth, or memorability. + +You optimize for experience quality, proximity, and narrative flow rather than maximizing the number of activities. + +--- + +Here is the traveler's itinerary: + +{trip_summary} + +--- + +Structure your response as follows: + +## 1. Destination Intelligence +For each destination, provide curated insights including must-do experiences, hidden gems, food highlights, and local tips. Focus on what meaningfully elevates the experience. + +## 2. Day-by-Day Enhancements +Review the itinerary day by day. Enhance existing plans, identify light or overloaded days, and suggest 1–2 optional improvements only when they clearly add value. + +## 3. Trip-Wide Guidance +Share advice that applies across the entire trip, including pacing, packing, cultural etiquette, timing considerations, and one high-impact surprise recommendation. + +Keep recommendations selective, actionable, and easy to apply. Prioritize clarity, flow, and memorability over completeness. + +--- + +Follow these rules at all times: +• Never repeat what is already obvious from the itinerary unless adding clear, new value. +• Do not suggest activities that conflict with existing bookings, transport, or fixed commitments. +• If a day is already full or demanding, do not add more activities; focus on optimizations instead. +• Keep recommendations intentionally limited and high-quality. +• Prioritize experiences that are culturally meaningful, locally respected, or uniquely memorable. +• Be specific and actionable with real names, precise locations, and ideal timing. +• Avoid repeating similar recommendations across destinations or days. +• Allow space for rest, spontaneity, and unplanned discovery. +• If nothing would meaningfully improve the itinerary, clearly say so. + +--- + +Act as if you have one opportunity to make this the most memorable trip of the traveler's life. + +Be thoughtful, selective, and intentional rather than comprehensive. + +Every recommendation should earn its place.""" + + # OpenAI prompt (structured with emoji sections) + openai_prompt = f"""You are an expert travel advisor and tour guide. Analyze this trip itinerary and provide helpful, actionable suggestions. + +{trip_summary} + +Based on this trip, please provide: + +## 🎯 Missing Must-See Attractions +List 3-5 highly-rated attractions or experiences in the destinations that are NOT already in the itinerary. Include why each is worth visiting. + +## 🍽️ Restaurant Recommendations +Suggest 2-3 well-reviewed local restaurants near their lodging locations. Include cuisine type and price range. + +## ⚡ Schedule Optimization +Identify any gaps, inefficiencies, or timing issues in their itinerary. Suggest improvements. + +## 💎 Hidden Gems +Share 2-3 lesser-known local experiences, viewpoints, or activities that tourists often miss. + +## 💡 Practical Tips +Provide 3-5 specific, actionable tips for this trip (booking requirements, best times to visit, local customs, what to bring, etc.) + +Format your response with clear headers and bullet points. Be specific with names, locations, and practical details. Use your knowledge of these destinations to give genuinely helpful advice.""" + + # Call the appropriate AI provider + if provider == "openai": + messages = [ + {"role": "system", "content": "You are an expert travel advisor who provides specific, actionable travel recommendations."}, + {"role": "user", "content": openai_prompt} + ] + result = call_openai(messages, max_completion_tokens=4000) + else: + # Gemini with search grounding + result = call_gemini(prompt, use_search_grounding=True) + + if isinstance(result, dict) and "error" in result: + self.send_json(result, 500) + return + + # Save suggestions to database (separate columns per provider) + conn = get_db() + cursor = conn.cursor() + if provider == "openai": + cursor.execute("UPDATE trips SET ai_suggestions_openai = ? WHERE id = ?", (result, trip_id)) + else: + cursor.execute("UPDATE trips SET ai_suggestions = ? WHERE id = ?", (result, trip_id)) + conn.commit() + conn.close() + + self.send_json({"suggestions": result}) + + # ==================== Quick Add Handlers ==================== + + def handle_quick_add(self, body): + """Create a new quick add entry.""" + data = json.loads(body) + + trip_id = data.get("trip_id") + name = data.get("name", "").strip() + category = data.get("category", "attraction") + place_id = data.get("place_id") + address = data.get("address") + latitude = data.get("latitude") + longitude = data.get("longitude") + photo_data = data.get("photo") # Base64 encoded + note = data.get("note", "") + captured_at = data.get("captured_at") # ISO format from client + attached_to_id = data.get("attached_to_id") + attached_to_type = data.get("attached_to_type") + + if not trip_id: + self.send_json({"error": "trip_id required"}, 400) + return + + if not name and not photo_data: + self.send_json({"error": "name or photo required"}, 400) + return + + # Generate ID + quick_add_id = str(uuid.uuid4()) + + # Save photo if provided + photo_path = None + if photo_data: + try: + # Check if photo is already saved (from Immich or Google Photos) + if photo_data.startswith("immich:") or photo_data.startswith("google:"): + # Photo already saved, just use the path + photo_path = photo_data.split(":", 1)[1] + else: + # Base64 encoded photo from camera + # Remove data URL prefix if present + if "," in photo_data: + photo_data = photo_data.split(",")[1] + + photo_bytes = base64.b64decode(photo_data) + photo_id = str(uuid.uuid4()) + photo_path = f"{photo_id}.jpg" + + with open(IMAGES_DIR / photo_path, "wb") as f: + f.write(photo_bytes) + except Exception as e: + print(f"Error saving photo: {e}") + + # Use current time if not provided + if not captured_at: + captured_at = datetime.now().isoformat() + + # Determine status + status = "attached" if attached_to_id else "pending" + + conn = get_db() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO quick_adds (id, trip_id, name, category, place_id, address, + latitude, longitude, photo_path, note, captured_at, + status, attached_to_id, attached_to_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (quick_add_id, trip_id, name, category, place_id, address, + latitude, longitude, photo_path, note, captured_at, + status, attached_to_id, attached_to_type)) + + # If attaching to existing item, add photo to images table + if attached_to_id and attached_to_type and photo_path: + image_id = str(uuid.uuid4()) + cursor.execute(''' + INSERT INTO images (id, entity_type, entity_id, file_path, is_primary) + VALUES (?, ?, ?, ?, ?) + ''', (image_id, attached_to_type, attached_to_id, photo_path, 0)) + + conn.commit() + conn.close() + + self.send_json({ + "success": True, + "id": quick_add_id, + "status": status, + "photo_path": photo_path + }) + + def handle_quick_add_approve(self, body): + """Approve a quick add entry and add it to the itinerary.""" + data = json.loads(body) + quick_add_id = data.get("id") + + if not quick_add_id: + self.send_json({"error": "id required"}, 400) + return + + conn = get_db() + cursor = conn.cursor() + + # Get the quick add entry + cursor.execute("SELECT * FROM quick_adds WHERE id = ?", (quick_add_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + self.send_json({"error": "Quick add not found"}, 404) + return + + qa = dict(row) + + # Create a location entry from the quick add + location_id = str(uuid.uuid4()) + + # Parse captured_at for date and time + visit_date = None + start_time = None + end_time = None + if qa['captured_at']: + try: + # Format: 2025-12-25T13:43:24.198Z or 2025-12-25T13:43:24 + captured = qa['captured_at'].replace('Z', '').split('.')[0] + visit_date = captured[:10] # YYYY-MM-DD + start_time = captured # Full datetime for start + # End time = start + 1 hour + from datetime import datetime, timedelta + dt = datetime.fromisoformat(captured) + end_dt = dt + timedelta(hours=1) + end_time = end_dt.strftime('%Y-%m-%dT%H:%M:%S') + except: + visit_date = qa['captured_at'][:10] if len(qa['captured_at']) >= 10 else None + + cursor.execute(''' + INSERT INTO locations (id, trip_id, name, category, description, + latitude, longitude, visit_date, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (location_id, qa['trip_id'], qa['name'], qa['category'], + qa['note'] or '', qa['latitude'], qa['longitude'], + visit_date, start_time, end_time)) + + # If there's a photo, add it to the images table + if qa['photo_path']: + image_id = str(uuid.uuid4()) + cursor.execute(''' + INSERT INTO images (id, entity_type, entity_id, file_path, is_primary) + VALUES (?, ?, ?, ?, ?) + ''', (image_id, 'location', location_id, qa['photo_path'], 1)) + + # Update quick add status + cursor.execute("UPDATE quick_adds SET status = 'approved' WHERE id = ?", (quick_add_id,)) + + conn.commit() + conn.close() + + self.send_json({"success": True, "location_id": location_id}) + + def handle_quick_add_delete(self, body): + """Delete a quick add entry.""" + data = json.loads(body) + quick_add_id = data.get("id") + + if not quick_add_id: + self.send_json({"error": "id required"}, 400) + return + + conn = get_db() + cursor = conn.cursor() + + # Get photo path before deleting + cursor.execute("SELECT photo_path FROM quick_adds WHERE id = ?", (quick_add_id,)) + row = cursor.fetchone() + + if row and row['photo_path']: + photo_file = IMAGES_DIR / row['photo_path'] + if photo_file.exists(): + photo_file.unlink() + + cursor.execute("DELETE FROM quick_adds WHERE id = ?", (quick_add_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_quick_add_attach(self, body): + """Attach a quick add photo to an existing itinerary item.""" + data = json.loads(body) + quick_add_id = data.get("id") + target_id = data.get("target_id") + target_type = data.get("target_type") # transportation, lodging, location + + if not all([quick_add_id, target_id, target_type]): + self.send_json({"error": "id, target_id, and target_type required"}, 400) + return + + conn = get_db() + cursor = conn.cursor() + + # Get the quick add entry + cursor.execute("SELECT * FROM quick_adds WHERE id = ?", (quick_add_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + self.send_json({"error": "Quick add not found"}, 404) + return + + qa = dict(row) + + # Update the target item with the photo + table_name = { + "transportation": "transportations", + "lodging": "lodging", + "location": "locations" + }.get(target_type) + + if not table_name: + conn.close() + self.send_json({"error": "Invalid target_type"}, 400) + return + + if qa['photo_path']: + cursor.execute(f"UPDATE {table_name} SET image_path = ? WHERE id = ?", + (qa['photo_path'], target_id)) + + # Update quick add status + cursor.execute(''' + UPDATE quick_adds + SET status = 'attached', attached_to_id = ?, attached_to_type = ? + WHERE id = ? + ''', (target_id, target_type, quick_add_id)) + + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_places_autocomplete(self, body): + """Get Google Places autocomplete suggestions using Places API (New).""" + data = json.loads(body) + query = data.get("query", "").strip() + latitude = data.get("latitude") + longitude = data.get("longitude") + + if not query or len(query) < 2: + self.send_json({"predictions": []}) + return + + if not GOOGLE_API_KEY: + self.send_json({"error": "Google API key not configured"}, 500) + return + + # Build request body for Places API (New) + request_body = { + "input": query + } + + # Add location bias if provided + if latitude and longitude: + request_body["locationBias"] = { + "circle": { + "center": { + "latitude": float(latitude), + "longitude": float(longitude) + }, + "radius": 10000.0 # 10km radius + } + } + + url = "https://places.googleapis.com/v1/places:autocomplete" + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + req_data = json.dumps(request_body).encode('utf-8') + req = urllib.request.Request(url, data=req_data, method='POST') + req.add_header('Content-Type', 'application/json') + req.add_header('X-Goog-Api-Key', GOOGLE_API_KEY) + + with urllib.request.urlopen(req, context=ssl_context, timeout=10) as response: + result = json.loads(response.read().decode()) + + predictions = [] + for suggestion in result.get("suggestions", []): + place_pred = suggestion.get("placePrediction", {}) + if place_pred: + # Extract place ID from the place resource name + place_name = place_pred.get("place", "") + place_id = place_name.replace("places/", "") if place_name else "" + + main_text = place_pred.get("structuredFormat", {}).get("mainText", {}).get("text", "") + secondary_text = place_pred.get("structuredFormat", {}).get("secondaryText", {}).get("text", "") + + predictions.append({ + "place_id": place_id, + "name": main_text, + "address": secondary_text, + "description": place_pred.get("text", {}).get("text", ""), + "types": place_pred.get("types", []) + }) + + self.send_json({"predictions": predictions}) + except Exception as e: + print(f"Places autocomplete error: {e}") + self.send_json({"error": str(e)}, 500) + + # ==================== Immich Integration ==================== + + def handle_immich_photos(self, body): + """Get photos from Immich server, optionally filtered by date range.""" + if not IMMICH_URL or not IMMICH_API_KEY: + self.send_json({"error": "Immich not configured", "photos": []}) + return + + data = json.loads(body) if body else {} + start_date = data.get("start_date") # Optional: filter by trip dates + end_date = data.get("end_date") + page = data.get("page", 1) + per_page = data.get("per_page", 50) + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Use search/metadata endpoint for date filtering + search_url = f"{IMMICH_URL}/api/search/metadata" + + search_body = { + "type": "IMAGE", + "size": per_page, + "page": page, + "order": "desc" + } + + if start_date: + search_body["takenAfter"] = f"{start_date}T00:00:00.000Z" + if end_date: + search_body["takenBefore"] = f"{end_date}T23:59:59.999Z" + + req_data = json.dumps(search_body).encode('utf-8') + req = urllib.request.Request(search_url, data=req_data, method='POST') + req.add_header('Content-Type', 'application/json') + req.add_header('x-api-key', IMMICH_API_KEY) + + with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: + result = json.loads(response.read().decode()) + + photos = [] + assets = result.get("assets", {}).get("items", []) + + for asset in assets: + photos.append({ + "id": asset.get("id"), + "thumbnail_url": f"{IMMICH_URL}/api/assets/{asset.get('id')}/thumbnail", + "original_url": f"{IMMICH_URL}/api/assets/{asset.get('id')}/original", + "filename": asset.get("originalFileName", ""), + "date": asset.get("fileCreatedAt", ""), + "type": asset.get("type", "IMAGE") + }) + + self.send_json({ + "photos": photos, + "total": result.get("assets", {}).get("total", len(photos)), + "page": page + }) + + except Exception as e: + print(f"Immich error: {e}") + self.send_json({"error": str(e), "photos": []}) + + def handle_immich_download(self, body): + """Download a photo from Immich and save it locally.""" + if not IMMICH_URL or not IMMICH_API_KEY: + self.send_json({"error": "Immich not configured"}, 500) + return + + data = json.loads(body) + asset_id = data.get("asset_id") + + if not asset_id: + self.send_json({"error": "Missing asset_id"}, 400) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Download original image from Immich + download_url = f"{IMMICH_URL}/api/assets/{asset_id}/original" + req = urllib.request.Request(download_url) + req.add_header('x-api-key', IMMICH_API_KEY) + + with urllib.request.urlopen(req, context=ssl_context, timeout=60) as response: + image_data = response.read() + content_type = response.headers.get('Content-Type', 'image/jpeg') + + # Determine file extension + ext = '.jpg' + if 'png' in content_type: + ext = '.png' + elif 'webp' in content_type: + ext = '.webp' + elif 'heic' in content_type: + ext = '.heic' + + # Save to images directory + filename = f"{uuid.uuid4()}{ext}" + filepath = IMAGES_DIR / filename + + with open(filepath, 'wb') as f: + f.write(image_data) + + # If entity info provided, also create DB record + entity_type = data.get("entity_type") + entity_id = data.get("entity_id") + image_id = None + if entity_type and entity_id: + image_id = str(uuid.uuid4()) + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO images (id, entity_type, entity_id, file_path, is_primary) + VALUES (?, ?, ?, ?, ?) + ''', (image_id, entity_type, entity_id, filename, 0)) + conn.commit() + conn.close() + + self.send_json({ + "success": True, + "file_path": filename, + "image_id": image_id, + "size": len(image_data) + }) + + except Exception as e: + print(f"Immich download error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_immich_thumbnail(self, asset_id): + """Proxy thumbnail requests to Immich with authentication.""" + if not IMMICH_URL or not IMMICH_API_KEY: + self.send_error(404) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + thumb_url = f"{IMMICH_URL}/api/assets/{asset_id}/thumbnail" + req = urllib.request.Request(thumb_url) + req.add_header('x-api-key', IMMICH_API_KEY) + + with urllib.request.urlopen(req, context=ssl_context, timeout=10) as response: + image_data = response.read() + content_type = response.headers.get('Content-Type', 'image/jpeg') + + self.send_response(200) + self.send_header('Content-Type', content_type) + self.send_header('Content-Length', len(image_data)) + self.send_header('Cache-Control', 'max-age=3600') # Cache for 1 hour + self.end_headers() + self.wfile.write(image_data) + + except Exception as e: + print(f"Immich thumbnail error: {e}") + self.send_error(404) + + def handle_immich_albums(self): + """Get list of albums from Immich.""" + if not IMMICH_URL or not IMMICH_API_KEY: + self.send_json({"error": "Immich not configured", "albums": []}) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + url = f"{IMMICH_URL}/api/albums" + req = urllib.request.Request(url) + req.add_header('x-api-key', IMMICH_API_KEY) + + with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: + albums = json.loads(response.read().decode()) + + # Simplify album data + result = [] + for album in albums: + result.append({ + "id": album.get("id"), + "name": album.get("albumName", "Untitled"), + "asset_count": album.get("assetCount", 0), + "thumbnail_id": album.get("albumThumbnailAssetId"), + "created_at": album.get("createdAt"), + "updated_at": album.get("updatedAt") + }) + + # Sort by name + result.sort(key=lambda x: x["name"].lower()) + + self.send_json({"albums": result}) + + except Exception as e: + print(f"Immich albums error: {e}") + self.send_json({"error": str(e), "albums": []}) + + def handle_immich_album_photos(self, body): + """Get photos from a specific Immich album.""" + if not IMMICH_URL or not IMMICH_API_KEY: + self.send_json({"error": "Immich not configured", "photos": []}) + return + + data = json.loads(body) if body else {} + album_id = data.get("album_id") + + if not album_id: + self.send_json({"error": "Missing album_id", "photos": []}, 400) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + url = f"{IMMICH_URL}/api/albums/{album_id}" + req = urllib.request.Request(url) + req.add_header('x-api-key', IMMICH_API_KEY) + + with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: + album = json.loads(response.read().decode()) + + photos = [] + for asset in album.get("assets", []): + if asset.get("type") == "IMAGE": + photos.append({ + "id": asset.get("id"), + "thumbnail_url": f"/api/immich/thumb/{asset.get('id')}", + "filename": asset.get("originalFileName", ""), + "date": asset.get("fileCreatedAt", "") + }) + + self.send_json({ + "album_name": album.get("albumName", ""), + "photos": photos, + "total": len(photos) + }) + + except Exception as e: + print(f"Immich album photos error: {e}") + self.send_json({"error": str(e), "photos": []}) + + # ==================== Google Photos Integration ==================== + + def handle_google_photos_picker_config(self): + """Return config for Google Photos Picker.""" + if not GOOGLE_CLIENT_ID: + self.send_json({"error": "Google Photos not configured"}) + return + + self.send_json({ + "client_id": GOOGLE_CLIENT_ID, + "api_key": GOOGLE_API_KEY, + "app_id": GOOGLE_CLIENT_ID.split('-')[0] # Project number from client ID + }) + + def handle_google_photos_download(self, body): + """Download a photo from Google Photos using the provided access token and URL.""" + data = json.loads(body) + photo_url = data.get("url") + access_token = data.get("access_token") + + if not photo_url: + self.send_json({"error": "Missing photo URL"}, 400) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Download the photo + req = urllib.request.Request(photo_url) + if access_token: + req.add_header('Authorization', f'Bearer {access_token}') + + with urllib.request.urlopen(req, context=ssl_context, timeout=60) as response: + image_data = response.read() + content_type = response.headers.get('Content-Type', 'image/jpeg') + + # Determine file extension + ext = '.jpg' + if 'png' in content_type: + ext = '.png' + elif 'webp' in content_type: + ext = '.webp' + + # Save to images directory + filename = f"{uuid.uuid4()}{ext}" + filepath = IMAGES_DIR / filename + + with open(filepath, 'wb') as f: + f.write(image_data) + + self.send_json({ + "success": True, + "file_path": filename, + "size": len(image_data) + }) + + except Exception as e: + print(f"Google Photos download error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_google_photos_create_session(self, body): + """Create a Google Photos Picker session.""" + data = json.loads(body) + access_token = data.get("access_token") + + if not access_token: + self.send_json({"error": "Missing access token"}, 400) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Create session via Photos Picker API + url = "https://photospicker.googleapis.com/v1/sessions" + req = urllib.request.Request(url, method='POST') + req.add_header('Authorization', f'Bearer {access_token}') + req.add_header('Content-Type', 'application/json') + req.data = b'{}' + + with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: + result = json.loads(response.read().decode()) + + self.send_json(result) + + except urllib.error.HTTPError as e: + error_body = e.read().decode() if e.fp else str(e) + print(f"Google Photos create session error: {e.code} - {error_body}") + self.send_json({"error": f"API error: {e.code}"}, e.code) + except Exception as e: + print(f"Google Photos create session error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_google_photos_check_session(self, body): + """Check Google Photos Picker session status.""" + data = json.loads(body) + access_token = data.get("access_token") + session_id = data.get("session_id") + + if not access_token or not session_id: + self.send_json({"error": "Missing token or session_id"}, 400) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + url = f"https://photospicker.googleapis.com/v1/sessions/{session_id}" + req = urllib.request.Request(url) + req.add_header('Authorization', f'Bearer {access_token}') + + with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: + result = json.loads(response.read().decode()) + + self.send_json(result) + + except Exception as e: + print(f"Google Photos check session error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_google_photos_get_media_items(self, body): + """Get selected media items from a Google Photos Picker session.""" + data = json.loads(body) + access_token = data.get("access_token") + session_id = data.get("session_id") + + if not access_token or not session_id: + self.send_json({"error": "Missing token or session_id"}, 400) + return + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + url = f"https://photospicker.googleapis.com/v1/mediaItems?sessionId={session_id}&pageSize=50" + req = urllib.request.Request(url) + req.add_header('Authorization', f'Bearer {access_token}') + + with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: + result = json.loads(response.read().decode()) + + self.send_json(result) + + except Exception as e: + print(f"Google Photos get media items error: {e}") + self.send_json({"error": str(e)}, 500) + + def handle_get_active_trip(self): + """Get the currently active trip based on today's date.""" + today = datetime.now().strftime("%Y-%m-%d") + + conn = get_db() + cursor = conn.cursor() + + # Find trip where today falls within start_date and end_date + cursor.execute(''' + SELECT id, name, start_date, end_date, image_path + FROM trips + WHERE start_date <= ? AND end_date >= ? + ORDER BY start_date DESC + LIMIT 1 + ''', (today, today)) + + row = cursor.fetchone() + conn.close() + + if row: + self.send_json({ + "active": True, + "trip": dict(row) + }) + else: + self.send_json({"active": False, "trip": None}) + + def handle_get_stats(self): + """Get aggregate stats across all trips.""" + import math + + def parse_city_country(address): + """Extract city and country from a Google Places formatted address. + Format: 'Street, City, State ZIP, Country' or 'City, Country' etc.""" + if not address: + return None, None + parts = [p.strip() for p in address.split(',')] + # Filter out parts that look like streets, zip codes, P.O. boxes + parts = [p for p in parts if p and not re.match(r'^\d', p) + and not p.upper().startswith('P.O.') + and len(p) > 2] + if not parts: + return None, None + country = parts[-1] if len(parts) >= 2 else None + # City is typically second-to-last, or the part before "State ZIP" + city = None + if len(parts) >= 3: + # "Street, City, State ZIP, Country" -> city is parts[-3] or parts[-2] + candidate = parts[-2] + # If it looks like "CO 80904" or "TX" (state), go one more back + if re.match(r'^[A-Z]{2}\s*\d', candidate) or (len(candidate) == 2 and candidate.isupper()): + city = parts[-3] if len(parts) >= 3 else None + else: + city = candidate + elif len(parts) == 2: + city = parts[0] + return city, country + + conn = get_db() + cursor = conn.cursor() + + # Total trips + cursor.execute("SELECT COUNT(*) FROM trips") + total_trips = cursor.fetchone()[0] + + # Collect cities and countries from all addresses + cities = set() + countries = set() + + # From locations + cursor.execute("SELECT address FROM locations WHERE address IS NOT NULL AND address != ''") + for row in cursor.fetchall(): + city, country = parse_city_country(row[0]) + if city: + cities.add(city) + if country: + countries.add(country) + + # From lodging + cursor.execute("SELECT location FROM lodging WHERE location IS NOT NULL AND location != ''") + for row in cursor.fetchall(): + city, country = parse_city_country(row[0]) + if city: + cities.add(city) + if country: + countries.add(country) + + # From transportation (departure/arrival cities) + cursor.execute("SELECT from_location, to_location FROM transportations WHERE from_location != '' OR to_location != ''") + for row in cursor.fetchall(): + for loc in row: + if loc: + # Transport locations are often "City (CODE)" or "Airport Name (CODE)" + # Extract city name before parentheses + clean = re.sub(r'\s*\([^)]*\)\s*', '', loc).strip() + if clean and len(clean) > 2 and not re.match(r'^[A-Z]{3}$', clean): + cities.add(clean) + + # Clean up cities: remove airports, duplicates with country suffix + city_blacklist_patterns = [r'airport', r'international', r'\b[A-Z]{3}\b airport'] + cleaned_cities = set() + for c in cities: + if any(re.search(p, c, re.IGNORECASE) for p in city_blacklist_patterns): + continue + # Remove country suffix like "Muscat, Oman" -> "Muscat" + if ',' in c: + c = c.split(',')[0].strip() + # Skip postal codes like "Madinah 41419" + c = re.sub(r'\s+\d{4,}$', '', c).strip() + if c and len(c) > 2: + cleaned_cities.add(c) + cities = cleaned_cities + + # Normalize: merge "USA"/"United States", etc. + country_aliases = { + 'USA': 'United States', 'US': 'United States', 'U.S.A.': 'United States', + 'UK': 'United Kingdom', 'UAE': 'United Arab Emirates', + } + normalized_countries = set() + for c in countries: + # Skip postal codes or numeric-heavy entries + if re.match(r'^[A-Z]{2}\s+\d', c) or re.match(r'^\d', c): + continue + normalized_countries.add(country_aliases.get(c, c)) + + # Total points/miles redeemed across all bookings + total_points = 0 + for table in ['transportations', 'lodging', 'locations']: + cursor.execute(f"SELECT COALESCE(SUM(cost_points), 0) FROM {table}") + total_points += cursor.fetchone()[0] + + # Total activities + cursor.execute("SELECT COUNT(*) FROM locations") + total_activities = cursor.fetchone()[0] + + # --- Breakdown data for stat detail modals --- + + # Trips by year + trips_by_year = {} + cursor.execute("SELECT id, name, start_date, end_date FROM trips ORDER BY start_date DESC") + for row in cursor.fetchall(): + r = dict(row) + year = r['start_date'][:4] if r.get('start_date') else 'Unknown' + trips_by_year.setdefault(year, []).append({ + "id": r['id'], "name": r['name'], + "start_date": r.get('start_date', ''), + "end_date": r.get('end_date', '') + }) + + # Cities by country - re-parse addresses to build the mapping + cities_by_country = {} + # From locations + cursor.execute("SELECT l.address, t.id as trip_id, t.name as trip_name FROM locations l JOIN trips t ON l.trip_id = t.id WHERE l.address IS NOT NULL AND l.address != ''") + for row in cursor.fetchall(): + city, country = parse_city_country(row[0]) + if city and country: + # Clean city + if any(re.search(p, city, re.IGNORECASE) for p in city_blacklist_patterns): + continue + if ',' in city: + city = city.split(',')[0].strip() + city = re.sub(r'\s+\d{4,}$', '', city).strip() + if not city or len(city) <= 2: + continue + country = country_aliases.get(country, country) + if re.match(r'^[A-Z]{2}\s+\d', country) or re.match(r'^\d', country): + continue + cities_by_country.setdefault(country, set()).add(city) + + # From lodging + cursor.execute("SELECT l.location, t.id as trip_id, t.name as trip_name FROM lodging l JOIN trips t ON l.trip_id = t.id WHERE l.location IS NOT NULL AND l.location != ''") + for row in cursor.fetchall(): + city, country = parse_city_country(row[0]) + if city and country: + if any(re.search(p, city, re.IGNORECASE) for p in city_blacklist_patterns): + continue + if ',' in city: + city = city.split(',')[0].strip() + city = re.sub(r'\s+\d{4,}$', '', city).strip() + if not city or len(city) <= 2: + continue + country = country_aliases.get(country, country) + if re.match(r'^[A-Z]{2}\s+\d', country) or re.match(r'^\d', country): + continue + cities_by_country.setdefault(country, set()).add(city) + + # Convert sets to sorted lists + cities_by_country = {k: sorted(v) for k, v in sorted(cities_by_country.items())} + + # Points by year (based on trip start_date) + points_by_year = {} + for table in ['transportations', 'lodging', 'locations']: + cursor.execute(f""" + SELECT substr(t.start_date, 1, 4) as year, COALESCE(SUM(b.cost_points), 0) as pts + FROM {table} b JOIN trips t ON b.trip_id = t.id + WHERE t.start_date IS NOT NULL + GROUP BY year + """) + for row in cursor.fetchall(): + yr = row[0] or 'Unknown' + points_by_year[yr] = points_by_year.get(yr, 0) + row[1] + + # Points by category + points_by_category = {} + cursor.execute("SELECT COALESCE(SUM(cost_points), 0) FROM transportations") + points_by_category['flights'] = cursor.fetchone()[0] + cursor.execute("SELECT COALESCE(SUM(cost_points), 0) FROM lodging") + points_by_category['hotels'] = cursor.fetchone()[0] + cursor.execute("SELECT COALESCE(SUM(cost_points), 0) FROM locations") + points_by_category['activities'] = cursor.fetchone()[0] + + conn.close() + + self.send_json({ + "total_trips": total_trips, + "cities_visited": len(cities), + "countries_visited": len(normalized_countries), + "total_points_redeemed": round(total_points), + "total_activities": total_activities, + "cities": sorted(cities), + "countries": sorted(normalized_countries), + "trips_by_year": trips_by_year, + "cities_by_country": cities_by_country, + "points_by_year": {k: round(v) for k, v in sorted(points_by_year.items(), reverse=True)}, + "points_by_category": {k: round(v) for k, v in points_by_category.items()} + }) + + def handle_search(self, query): + """Search across all trips, locations, lodging, transportations, and notes.""" + if not query or len(query) < 2: + self.send_json({"results": []}) + return + + conn = get_db() + cursor = conn.cursor() + q = f"%{query}%" + results = [] + + # Search trips + cursor.execute("SELECT id, name, start_date, end_date FROM trips WHERE name LIKE ? OR description LIKE ?", (q, q)) + for row in cursor.fetchall(): + r = dict(row) + results.append({"type": "trip", "id": r["id"], "trip_id": r["id"], + "name": r["name"], "detail": f'{r.get("start_date", "")} - {r.get("end_date", "")}', + "trip_name": r["name"]}) + + # Search locations + cursor.execute("""SELECT l.id, l.name, l.address, l.category, l.trip_id, t.name as trip_name + FROM locations l JOIN trips t ON l.trip_id = t.id + WHERE l.name LIKE ? OR l.address LIKE ? OR l.description LIKE ?""", (q, q, q)) + for row in cursor.fetchall(): + r = dict(row) + detail = r.get("address") or r.get("category") or "" + results.append({"type": "location", "id": r["id"], "trip_id": r["trip_id"], + "name": r["name"], "detail": detail, "trip_name": r["trip_name"]}) + + # Search lodging + cursor.execute("""SELECT l.id, l.name, l.location, l.trip_id, t.name as trip_name + FROM lodging l JOIN trips t ON l.trip_id = t.id + WHERE l.name LIKE ? OR l.location LIKE ? OR l.reservation_number LIKE ? OR l.description LIKE ?""", (q, q, q, q)) + for row in cursor.fetchall(): + r = dict(row) + results.append({"type": "lodging", "id": r["id"], "trip_id": r["trip_id"], + "name": r["name"], "detail": r.get("location", ""), "trip_name": r["trip_name"]}) + + # Search transportations + cursor.execute("""SELECT tr.id, tr.name, tr.flight_number, tr.from_location, tr.to_location, tr.trip_id, t.name as trip_name + FROM transportations tr JOIN trips t ON tr.trip_id = t.id + WHERE tr.name LIKE ? OR tr.flight_number LIKE ? OR tr.from_location LIKE ? OR tr.to_location LIKE ? OR tr.description LIKE ?""", + (q, q, q, q, q)) + for row in cursor.fetchall(): + r = dict(row) + name = r["name"] or r.get("flight_number") or "" + detail = f'{r.get("from_location", "")} → {r.get("to_location", "")}' + results.append({"type": "transportation", "id": r["id"], "trip_id": r["trip_id"], + "name": name, "detail": detail, "trip_name": r["trip_name"]}) + + # Search notes + cursor.execute("""SELECT n.id, n.name, n.content, n.trip_id, t.name as trip_name + FROM notes n JOIN trips t ON n.trip_id = t.id + WHERE n.name LIKE ? OR n.content LIKE ?""", (q, q)) + for row in cursor.fetchall(): + r = dict(row) + results.append({"type": "note", "id": r["id"], "trip_id": r["trip_id"], + "name": r["name"], "detail": "", "trip_name": r["trip_name"]}) + + conn.close() + self.send_json({"results": results, "count": len(results)}) + + def handle_get_quick_adds(self, trip_id=None): + """Get pending quick adds, optionally filtered by trip.""" + conn = get_db() + cursor = conn.cursor() + + if trip_id: + cursor.execute(''' + SELECT * FROM quick_adds + WHERE trip_id = ? AND status = 'pending' + ORDER BY captured_at DESC + ''', (trip_id,)) + else: + cursor.execute(''' + SELECT qa.*, t.name as trip_name FROM quick_adds qa + JOIN trips t ON qa.trip_id = t.id + WHERE qa.status = 'pending' + ORDER BY qa.captured_at DESC + ''') + + rows = cursor.fetchall() + conn.close() + + quick_adds = [dict(row) for row in rows] + self.send_json({"quick_adds": quick_adds, "count": len(quick_adds)}) + + def handle_places_details(self, place_id): + """Get place details including lat/lng and types using Places API (New).""" + if not place_id: + self.send_json({"error": "place_id required"}, 400) + return + + if not GOOGLE_API_KEY: + self.send_json({"error": "Google API key not configured"}, 500) + return + + details = get_place_details(place_id) + if not details: + self.send_json({"error": "Could not fetch place details"}, 500) + return + + types = details.get("types", []) + primary_type = details.get("primary_type", "") + category = self.detect_category_from_types(types + [primary_type] if primary_type else types) + + self.send_json({ + "name": details.get("name", ""), + "address": details.get("address", ""), + "latitude": details.get("latitude"), + "longitude": details.get("longitude"), + "types": types, + "category": category + }) + + def detect_category_from_types(self, types): + """Map Google Places types to our categories.""" + type_mapping = { + # Food & Drink + "restaurant": "restaurant", + "cafe": "cafe", + "bar": "bar", + "bakery": "restaurant", + "food": "restaurant", + "meal_delivery": "restaurant", + "meal_takeaway": "restaurant", + # Attractions + "tourist_attraction": "attraction", + "museum": "museum", + "art_gallery": "museum", + "amusement_park": "attraction", + "aquarium": "attraction", + "zoo": "attraction", + "stadium": "attraction", + # Nature + "park": "park", + "natural_feature": "park", + "campground": "park", + # Shopping + "shopping_mall": "shopping", + "store": "shopping", + "supermarket": "shopping", + # Lodging + "lodging": "lodging", + "hotel": "lodging", + # Transportation + "airport": "airport", + "train_station": "train_station", + "transit_station": "transit", + "bus_station": "transit", + "car_rental": "car_rental", + "gas_station": "gas_station", + } + + for t in types: + if t in type_mapping: + return type_mapping[t] + + return "attraction" # Default + + def handle_get_pending_imports(self): + """Get all pending imports.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute(''' + SELECT id, entry_type, parsed_data, source, email_subject, email_from, status, created_at + FROM pending_imports + WHERE status = 'pending' + ORDER BY created_at DESC + ''') + rows = cursor.fetchall() + conn.close() + + imports = [] + for row in rows: + imports.append({ + "id": row[0], + "entry_type": row[1], + "parsed_data": json.loads(row[2]), + "source": row[3], + "email_subject": row[4], + "email_from": row[5], + "status": row[6], + "created_at": row[7] + }) + + self.send_json({"imports": imports, "count": len(imports)}) + + def handle_approve_import(self, body): + """Approve a pending import and add it to a trip.""" + data = json.loads(body) + import_id = data.get("import_id") + trip_id = data.get("trip_id") + + if not import_id or not trip_id: + self.send_json({"error": "import_id and trip_id required"}, 400) + return + + # Get the pending import + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT entry_type, parsed_data FROM pending_imports WHERE id = ?", (import_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + self.send_json({"error": "Import not found"}, 404) + return + + entry_type = row[0] + parsed_raw = json.loads(row[1]) + # The parsed data has a nested "data" field with the actual values + parsed_data = parsed_raw.get("data", parsed_raw) + + # Create the entry based on type + entry_id = str(uuid.uuid4()) + + if entry_type == "flight": + cursor.execute(''' + INSERT INTO transportations (id, trip_id, name, type, flight_number, from_location, to_location, date, end_date, timezone, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (entry_id, trip_id, parsed_data.get("name", ""), "plane", + parsed_data.get("flight_number", ""), parsed_data.get("from_location", ""), + parsed_data.get("to_location", ""), parsed_data.get("date", ""), + parsed_data.get("end_date", ""), parsed_data.get("timezone", ""), + parsed_data.get("description", ""))) + + elif entry_type == "hotel": + cursor.execute(''' + INSERT INTO lodging (id, trip_id, name, type, location, check_in, check_out, timezone, reservation_number, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (entry_id, trip_id, parsed_data.get("name", ""), parsed_data.get("type", "hotel"), + parsed_data.get("location", ""), parsed_data.get("check_in", ""), + parsed_data.get("check_out", ""), parsed_data.get("timezone", ""), + parsed_data.get("reservation_number", ""), parsed_data.get("description", ""))) + + elif entry_type == "note": + cursor.execute(''' + INSERT INTO notes (id, trip_id, name, content, date) + VALUES (?, ?, ?, ?, ?) + ''', (entry_id, trip_id, parsed_data.get("name", "Imported Note"), + parsed_data.get("content", ""), parsed_data.get("date", ""))) + + else: + conn.close() + self.send_json({"error": f"Unknown entry type: {entry_type}"}, 400) + return + + # Mark import as approved + cursor.execute("UPDATE pending_imports SET status = 'approved' WHERE id = ?", (import_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True, "entry_id": entry_id, "entry_type": entry_type}) + + def handle_delete_import(self, body): + """Delete a pending import.""" + data = json.loads(body) + import_id = data.get("import_id") + + if not import_id: + self.send_json({"error": "import_id required"}, 400) + return + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM pending_imports WHERE id = ?", (import_id,)) + conn.commit() + conn.close() + + self.send_json({"success": True}) + + def handle_share_api(self, share_token): + """Return trip data as JSON for a public share token.""" + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT * FROM trips WHERE share_token = ?", (share_token,)) + trip = dict_from_row(cursor.fetchone()) + + if not trip: + conn.close() + self.send_json({"error": "Trip not found"}, 404) + return + + trip_id = trip["id"] + + cursor.execute("SELECT * FROM transportations WHERE trip_id = ? ORDER BY date", (trip_id,)) + trip["transportations"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM lodging WHERE trip_id = ? ORDER BY check_in", (trip_id,)) + trip["lodging"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM notes WHERE trip_id = ? ORDER BY date", (trip_id,)) + trip["notes"] = [dict_from_row(row) for row in cursor.fetchall()] + + cursor.execute("SELECT * FROM locations WHERE trip_id = ? ORDER BY visit_date", (trip_id,)) + trip["locations"] = [dict_from_row(row) for row in cursor.fetchall()] + + # Images + entity_ids = [trip_id] + [i["id"] for i in trip["transportations"] + trip["lodging"] + trip["locations"] + trip["notes"]] + placeholders = ','.join('?' * len(entity_ids)) + cursor.execute(f"SELECT * FROM images WHERE entity_id IN ({placeholders}) ORDER BY is_primary DESC, created_at", entity_ids) + all_images = [dict_from_row(row) for row in cursor.fetchall()] + images_by_entity = {} + for img in all_images: + img["url"] = f'/images/{img["file_path"]}' + images_by_entity.setdefault(img["entity_id"], []).append(img) + + trip["images"] = images_by_entity.get(trip_id, []) + for t in trip["transportations"]: + t["images"] = images_by_entity.get(t["id"], []) + for l in trip["lodging"]: + l["images"] = images_by_entity.get(l["id"], []) + for l in trip["locations"]: + l["images"] = images_by_entity.get(l["id"], []) + + hero_images = list(images_by_entity.get(trip_id, [])) + for img in all_images: + if img["entity_id"] != trip_id and img not in hero_images: + hero_images.append(img) + trip["hero_images"] = hero_images + + conn.close() + self.send_json(trip) + + # handle_share_view removed — use /api/share/trip/{token} or SvelteKit /view/{token} + + + # HTML rendering removed — frontend is now SvelteKit (frontend/ directory) + +def main(): + """Main entry point.""" + init_db() + + # Start background weather prefetch + start_weather_prefetch_thread() + + server = HTTPServer(("0.0.0.0", PORT), TripHandler) + print(f"Trips server running on port {PORT}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/services/trips/sw.js b/services/trips/sw.js new file mode 100644 index 0000000..f87092c --- /dev/null +++ b/services/trips/sw.js @@ -0,0 +1,649 @@ +const CACHE_NAME = 'trips-v17'; +const STATIC_CACHE = 'trips-static-v17'; +const DATA_CACHE = 'trips-data-v17'; +const IMAGE_CACHE = 'trips-images-v17'; + +// Static assets to cache on install +const STATIC_ASSETS = [ + '/' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + // Delete old version caches + if (cacheName.startsWith('trips-') && + cacheName !== STATIC_CACHE && + cacheName !== DATA_CACHE && + cacheName !== IMAGE_CACHE) { + console.log('[SW] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Check if response is valid (not error page, not Cloudflare error) +function isValidResponse(response) { + if (!response) return false; + if (!response.ok) return false; + if (response.redirected) return false; + if (response.status !== 200) return false; + + // Check content-type to detect error pages + const contentType = response.headers.get('content-type') || ''; + + // If we expect HTML but got something else, it might be an error + return true; +} + +// Check if response is a Cloudflare/proxy error page +async function isErrorPage(response) { + if (!response) return true; + if (response.status >= 500) return true; + if (response.status === 0) return true; + + // Clone to read body without consuming + const clone = response.clone(); + try { + const text = await clone.text(); + // Detect common error page signatures + if (text.includes('cloudflare') && text.includes('Error')) return true; + if (text.includes('502 Bad Gateway')) return true; + if (text.includes('503 Service')) return true; + if (text.includes('504 Gateway')) return true; + } catch (e) { + // If we can't read it, assume it's fine + } + return false; +} + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Skip non-GET requests + if (event.request.method !== 'GET') { + return; + } + + // Never cache auth pages - always go to network + if (url.pathname === '/login' || url.pathname === '/logout' || url.pathname.startsWith('/auth')) { + return; + } + + // Handle CDN requests (Quill, etc.) - cache first + if (url.origin !== location.origin) { + if (url.hostname.includes('cdn.jsdelivr.net')) { + event.respondWith( + caches.match(event.request).then(cached => { + return cached || fetch(event.request); + }) + ); + } + return; + } + + // Handle API requests + if (url.pathname.startsWith('/api/')) { + event.respondWith(handleApiRequest(event.request)); + return; + } + + // Handle image requests + if (url.pathname.startsWith('/images/')) { + event.respondWith(handleImageRequest(event.request)); + return; + } + + // Handle document requests + if (url.pathname.startsWith('/documents/')) { + event.respondWith(handleDocumentRequest(event.request)); + return; + } + + // Handle trip pages - CACHE FIRST for offline support + if (url.pathname.startsWith('/trip/')) { + event.respondWith(handleTripPageRequest(event.request)); + return; + } + + // Handle main page - cache first, then pre-cache all trips + if (url.pathname === '/') { + event.respondWith(handleMainPageRequest(event.request)); + return; + } + + // Default: cache first, network fallback + event.respondWith(handleStaticRequest(event.request)); +}); + +// Handle main page - NETWORK FIRST, only cache when offline +async function handleMainPageRequest(request) { + // Try network first + try { + const response = await fetch(request); + + // If redirected (to login), clear cache and return the redirect + if (response.redirected) { + console.log('[SW] Main page redirected (likely to login), clearing cache'); + const cache = await caches.open(STATIC_CACHE); + cache.delete(request); + return response; + } + + if (response.ok) { + // Clone to read HTML and cache main page images + const htmlText = await response.clone().text(); + + const cache = await caches.open(STATIC_CACHE); + cache.put(request, response.clone()); + + // Cache main page images in background + cacheMainPageImages(htmlText); + + return response; + } + + // Non-OK response, just return it + return response; + } catch (error) { + // Network failed - we're offline, try cache + console.log('[SW] Main page network failed, trying cache'); + const cachedResponse = await caches.match(request); + if (cachedResponse) return cachedResponse; + return createOfflineResponse('Main page not available offline'); + } +} + +// Cache images from main page +async function cacheMainPageImages(html) { + const imageCache = await caches.open(IMAGE_CACHE); + const imageUrls = extractUrls(html, /\/images\/[^"'\s<>]+/g); + + for (const imgUrl of imageUrls) { + try { + const existing = await caches.match(imgUrl); + if (!existing) { + const imgResponse = await fetch(imgUrl); + if (imgResponse.ok) { + await imageCache.put(imgUrl, imgResponse); + console.log('[SW] Cached main page image:', imgUrl); + } + } + } catch (e) { /* skip */ } + } +} + +// Handle trip pages - NETWORK FIRST, fall back to cache only when offline +async function handleTripPageRequest(request) { + // Always try network first + try { + console.log('[SW] Fetching trip from network:', request.url); + const response = await fetch(request); + + // Check if response is valid + if (response.ok && response.status === 200) { + // Check if we got redirected to login/auth page (auth failure) + const finalUrl = response.url || ''; + if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) { + console.log('[SW] Redirected to auth, not caching:', finalUrl); + // Clear any cached version of this page since user is logged out + const cache = await caches.open(DATA_CACHE); + cache.delete(request); + return response; + } + + // Cache and return the valid response + const cache = await caches.open(DATA_CACHE); + cache.put(request, response.clone()); + console.log('[SW] Cached trip page:', request.url); + return response; + } + + // Non-200 response, just return it (might be login redirect) + console.log('[SW] Non-200 response:', response.status, request.url); + return response; + } catch (error) { + // Network failed - we're offline, try cache + console.log('[SW] Network failed, trying cache for trip:', request.url); + const cachedResponse = await caches.match(request); + if (cachedResponse) { + console.log('[SW] Serving trip from cache (offline):', request.url); + // Add header to indicate offline mode + const headers = new Headers(cachedResponse.headers); + headers.set('X-From-Cache', 'true'); + return new Response(cachedResponse.body, { + status: cachedResponse.status, + statusText: cachedResponse.statusText, + headers: headers + }); + } + return createOfflineResponse('Trip not available offline. Download this trip while online first.'); + } +} + +// Update trip cache in background +async function updateTripCache(request) { + try { + const response = await fetch(request); + if (response.ok && response.status === 200) { + // Skip if redirected to login/auth + const finalUrl = response.url || ''; + if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) return; + + const cache = await caches.open(DATA_CACHE); + cache.put(request, response.clone()); + console.log('[SW] Updated cache for:', request.url); + } + } catch (error) { + // Silent fail - we already served from cache + } +} + +// Send progress message to all clients +async function sendProgress(message, progress, total) { + const clients = await self.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ + type: 'CACHE_PROGRESS', + message, + progress, + total + }); + }); +} + +// Pre-cache all trips when main page loads +let preCacheInProgress = false; +async function preCacheAllTrips() { + // Prevent duplicate runs + if (preCacheInProgress) return; + preCacheInProgress = true; + + console.log('[SW] Pre-caching all trips (full offline mode)...'); + + // Small delay to ensure page JS has loaded + await new Promise(r => setTimeout(r, 500)); + + try { + const response = await fetch('/api/trips', { credentials: 'include' }); + if (!response.ok) { + console.log('[SW] Failed to fetch trips list:', response.status); + await sendProgress('Offline sync failed - not logged in?', 0, 0); + preCacheInProgress = false; + return; + } + + const trips = await response.json(); + const dataCache = await caches.open(DATA_CACHE); + const imageCache = await caches.open(IMAGE_CACHE); + + const totalTrips = trips.length; + let currentTrip = 0; + let totalAssets = 0; + let cachedAssets = 0; + + await sendProgress('Starting offline sync...', 0, totalTrips); + + // Cache the trips list API response + const tripsResponse = await fetch('/api/trips', { credentials: 'include' }); + if (tripsResponse.ok) { + dataCache.put('/api/trips', tripsResponse); + } + + // Cache each trip page and its assets + for (const trip of trips) { + currentTrip++; + const tripUrl = `/trip/${trip.id}`; + + await sendProgress(`Caching: ${trip.name}`, currentTrip, totalTrips); + + try { + const tripResponse = await fetch(tripUrl, { credentials: 'include' }); + if (tripResponse.ok && tripResponse.status === 200) { + // Skip if redirected to login/auth + const finalUrl = tripResponse.url || ''; + if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) { + console.log('[SW] Auth required for trip:', trip.name); + continue; + } + + // Clone response to read HTML and still cache it + const htmlText = await tripResponse.clone().text(); + await dataCache.put(tripUrl, tripResponse); + console.log('[SW] Pre-cached trip:', trip.name); + + // Extract and cache all images + const imageUrls = extractUrls(htmlText, /\/images\/[^"'\s<>]+/g); + totalAssets += imageUrls.length; + for (const imgUrl of imageUrls) { + try { + const existing = await caches.match(imgUrl); + if (!existing) { + const imgResponse = await fetch(imgUrl); + if (imgResponse.ok) { + await imageCache.put(imgUrl, imgResponse); + cachedAssets++; + console.log('[SW] Cached image:', imgUrl); + } + } else { + cachedAssets++; + } + } catch (e) { /* skip failed images */ } + } + + // Extract and cache all documents + const docUrls = extractUrls(htmlText, /\/documents\/[^"'\s<>]+/g); + totalAssets += docUrls.length; + for (const docUrl of docUrls) { + try { + const existing = await caches.match(docUrl); + if (!existing) { + const docResponse = await fetch(docUrl); + if (docResponse.ok) { + await dataCache.put(docUrl, docResponse); + cachedAssets++; + console.log('[SW] Cached document:', docUrl); + } + } else { + cachedAssets++; + } + } catch (e) { /* skip failed docs */ } + } + } + } catch (e) { + console.log('[SW] Failed to pre-cache trip:', trip.name, e); + } + } + + await sendProgress('complete', totalTrips, totalTrips); + console.log('[SW] Full pre-caching complete!', totalTrips, 'trips,', cachedAssets, 'files'); + preCacheInProgress = false; + } catch (error) { + console.error('[SW] Pre-caching failed:', error); + await sendProgress('Offline sync failed', 0, 0); + preCacheInProgress = false; + } +} + +// Helper to extract URLs from HTML using regex +function extractUrls(html, pattern) { + const matches = html.match(pattern) || []; + // Dedupe and clean + return [...new Set(matches.map(url => { + // Decode HTML entities and remove trailing quotes + return url.replace(/&/g, '&').replace(/["'<>]/g, ''); + }))]; +} + +// Handle static assets - cache first +async function handleStaticRequest(request) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + // Return cached, but also update cache in background + fetchAndCache(request, STATIC_CACHE); + return cachedResponse; + } + + return fetchAndCache(request, STATIC_CACHE); +} + +// Handle API requests - cache first for trip data, network for others +async function handleApiRequest(request) { + const url = new URL(request.url); + + // These endpoints can be cached for offline use + const cacheableEndpoints = [ + '/api/trips', + '/api/trip/' + ]; + + const shouldCache = cacheableEndpoints.some(endpoint => url.pathname.startsWith(endpoint)); + + // NETWORK FIRST - always try network, only use cache when offline + try { + const response = await fetch(request); + if (response.ok && shouldCache) { + const cache = await caches.open(DATA_CACHE); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Network failed - we're offline, try cache + console.log('[SW] API request failed (offline), trying cache:', request.url); + const cachedResponse = await caches.match(request); + if (cachedResponse) { + const headers = new Headers(cachedResponse.headers); + headers.set('X-From-Cache', 'true'); + return new Response(cachedResponse.body, { + status: cachedResponse.status, + statusText: cachedResponse.statusText, + headers: headers + }); + } + return createOfflineJsonResponse({ error: 'Offline - data not cached' }); + } +} + +// Fetch and cache API in background +async function fetchAndCacheApi(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(DATA_CACHE); + cache.put(request, response.clone()); + } + } catch (e) { + // Silent fail + } +} + +// Handle image requests - cache first (images don't change) +async function handleImageRequest(request) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(IMAGE_CACHE); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + console.log('[SW] Image not available offline:', request.url); + return createPlaceholderImage(); + } +} + +// Handle document requests - cache first +async function handleDocumentRequest(request) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(DATA_CACHE); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + console.log('[SW] Document not available offline:', request.url); + return createOfflineResponse('Document not available offline'); + } +} + +// Helper: fetch and cache +async function fetchAndCache(request, cacheName) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + throw error; + } +} + +// Helper: create offline HTML response +function createOfflineResponse(message) { + return new Response( + ` + + + + + Offline - Trips + + + +
+
📡
+

You're Offline

+

${message}

+ +
+ + `, + { + status: 503, + headers: { 'Content-Type': 'text/html' } + } + ); +} + +// Helper: create offline JSON response +function createOfflineJsonResponse(data) { + return new Response(JSON.stringify(data), { + status: 503, + headers: { + 'Content-Type': 'application/json', + 'X-From-Cache': 'false' + } + }); +} + +// Helper: create placeholder image +function createPlaceholderImage() { + // 1x1 transparent PNG + const placeholder = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + const binary = atob(placeholder); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Response(bytes, { + status: 200, + headers: { 'Content-Type': 'image/png' } + }); +} + +// Listen for messages from the main thread +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + // Cache specific trip data on demand + if (event.data && event.data.type === 'CACHE_TRIP') { + const tripId = event.data.tripId; + cacheTripData(tripId); + } + + // Clear all caches + if (event.data && event.data.type === 'CLEAR_CACHE') { + caches.keys().then((names) => { + names.forEach((name) => { + if (name.startsWith('trips-')) { + caches.delete(name); + } + }); + }); + } + + // Force pre-cache all trips + if (event.data && event.data.type === 'PRECACHE_ALL') { + preCacheAllTrips(); + } +}); + +// Cache trip data for offline use +async function cacheTripData(tripId) { + const cache = await caches.open(DATA_CACHE); + + try { + // Cache trip page + const tripPageUrl = `/trip/${tripId}`; + const tripPageResponse = await fetch(tripPageUrl); + if (tripPageResponse.ok) { + await cache.put(tripPageUrl, tripPageResponse); + } + + console.log('[SW] Cached trip data for:', tripId); + } catch (error) { + console.error('[SW] Failed to cache trip:', error); + } +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file