From aebe1868a2462e1eb7e3dd2e922cefd0a6f09b2e Mon Sep 17 00:00:00 2001 From: wojo Date: Sun, 1 Mar 2026 17:47:30 +0000 Subject: [PATCH] Inker v0.1.0 - Self-hosted e-ink device management system Full-stack application for managing TRMNL e-ink displays with screen designer, playlist management, custom widgets, and OTA updates. --- .env.example | 15 + .gitignore | 50 + Dockerfile | 217 + LICENSE | 172 + README.md | 122 + backend/.env.example | 36 + backend/.eslintignore | 6 + backend/.gitignore | 48 + backend/assets/.gitkeep | 0 backend/assets/default-screen.png | Bin 0 -> 12144 bytes backend/assets/fonts/Inter-Bold.woff2 | Bin 0 -> 17784 bytes backend/assets/fonts/Inter-Medium.woff2 | Bin 0 -> 17552 bytes backend/assets/fonts/Inter-Regular.woff2 | Bin 0 -> 16708 bytes backend/assets/fonts/Inter-SemiBold.woff2 | Bin 0 -> 17660 bytes backend/assets/fonts/Merriweather-Bold.woff2 | Bin 0 -> 19740 bytes .../assets/fonts/Merriweather-Regular.woff2 | Bin 0 -> 20028 bytes backend/assets/fonts/RobotoMono-Bold.woff2 | Bin 0 -> 12276 bytes backend/assets/fonts/RobotoMono-Medium.woff2 | Bin 0 -> 12532 bytes backend/assets/fonts/RobotoMono-Regular.woff2 | Bin 0 -> 12312 bytes backend/assets/setup.png | Bin 0 -> 19687 bytes backend/assets/test.png | Bin 0 -> 4624 bytes backend/bun.lock | 1410 ++ backend/docker-entrypoint.sh | 16 + backend/eslint.config.js | 44 + backend/nest-cli.json | 9 + backend/package-lock.json | 11518 ++++++++++++++++ backend/package.json | 81 + .../migration.sql | 20 + backend/prisma/schema.prisma | 296 + backend/prisma/seed.ts | 354 + backend/src/api/api.controller.test.ts | 118 + backend/src/api/api.controller.ts | 623 + backend/src/api/api.module.ts | 40 + .../src/api/display/default-screen.service.ts | 300 + .../src/api/display/display.service.test.ts | 323 + backend/src/api/display/display.service.ts | 715 + backend/src/api/log/dto/create-log.dto.ts | 58 + backend/src/api/log/log.service.test.ts | 74 + backend/src/api/log/log.service.ts | 104 + backend/src/api/setup/setup-screen.service.ts | 272 + backend/src/api/setup/setup.service.test.ts | 218 + backend/src/api/setup/setup.service.ts | 228 + backend/src/app.module.ts | 106 + backend/src/auth/auth.controller.test.ts | 95 + backend/src/auth/auth.controller.ts | 82 + backend/src/auth/auth.module.ts | 18 + backend/src/auth/dto/login.dto.ts | 10 + .../src/auth/guards/pin-auth.guard.test.ts | 58 + backend/src/auth/guards/pin-auth.guard.ts | 59 + backend/src/auth/pin-auth.service.test.ts | 92 + backend/src/auth/pin-auth.service.ts | 110 + backend/src/common/common.module.ts | 4 + .../src/common/constants/widget.constants.ts | 10 + .../src/common/decorators/public.decorator.ts | 8 + .../common/filters/http-exception.filter.ts | 61 + .../interceptors/transform.interceptor.ts | 56 + backend/src/common/utils/crypto.util.test.ts | 36 + backend/src/common/utils/crypto.util.ts | 15 + .../src/common/utils/response.util.test.ts | 65 + backend/src/common/utils/response.util.ts | 97 + backend/src/common/utils/url-safety.ts | 175 + backend/src/config/configuration.ts | 76 + backend/src/config/logger.config.ts | 36 + backend/src/config/validation.schema.ts | 35 + .../custom-widgets.controller.test.ts | 109 + .../custom-widgets.controller.ts | 72 + .../custom-widgets/custom-widgets.module.ts | 14 + .../custom-widgets.service.test.ts | 537 + .../custom-widgets/custom-widgets.service.ts | 551 + .../dto/create-custom-widget.dto.ts | 76 + .../dto/update-custom-widget.dto.ts | 77 + .../services/script-executor.service.test.ts | 262 + .../services/script-executor.service.ts | 178 + .../dashboard/dashboard.controller.test.ts | 68 + backend/src/dashboard/dashboard.controller.ts | 60 + backend/src/dashboard/dashboard.module.ts | 21 + .../src/dashboard/dashboard.service.test.ts | 69 + backend/src/dashboard/dashboard.service.ts | 139 + .../src/dashboard/dto/dashboard-stats.dto.ts | 83 + .../data-sources.controller.test.ts | 108 + .../data-sources/data-sources.controller.ts | 90 + .../src/data-sources/data-sources.module.ts | 12 + .../data-sources/data-sources.service.test.ts | 377 + .../src/data-sources/data-sources.service.ts | 831 ++ .../dto/create-data-source.dto.ts | 88 + backend/src/data-sources/dto/test-url.dto.ts | 28 + .../dto/update-data-source.dto.ts | 82 + .../src/devices/devices.controller.test.ts | 117 + backend/src/devices/devices.controller.ts | 161 + backend/src/devices/devices.module.ts | 12 + backend/src/devices/devices.service.test.ts | 281 + backend/src/devices/devices.service.ts | 474 + backend/src/devices/dto/create-device.dto.ts | 39 + backend/src/devices/dto/update-device.dto.ts | 59 + .../devices/entities/device.entity.test.ts | 95 + backend/src/devices/entities/device.entity.ts | 112 + backend/src/events/events.controller.ts | 108 + backend/src/events/events.module.ts | 13 + backend/src/events/events.service.test.ts | 214 + backend/src/events/events.service.ts | 251 + .../extensions/dto/create-extension.dto.ts | 45 + .../extensions/dto/update-extension.dto.ts | 46 + .../extensions/extensions.controller.test.ts | 85 + .../src/extensions/extensions.controller.ts | 84 + backend/src/extensions/extensions.module.ts | 12 + .../src/extensions/extensions.service.test.ts | 114 + backend/src/extensions/extensions.service.ts | 137 + .../src/firmware/dto/create-firmware.dto.ts | 39 + .../src/firmware/dto/update-firmware.dto.ts | 40 + .../src/firmware/firmware.controller.test.ts | 87 + backend/src/firmware/firmware.controller.ts | 109 + backend/src/firmware/firmware.module.ts | 12 + backend/src/firmware/firmware.service.test.ts | 127 + backend/src/firmware/firmware.service.ts | 189 + backend/src/health/health.controller.ts | 40 + backend/src/health/health.module.ts | 9 + backend/src/jobs/jobs.module.ts | 34 + .../jobs/processors/log-cleanup.processor.ts | 29 + .../jobs/processors/model-poller.processor.ts | 119 + .../src/jobs/services/log-cleanup.service.ts | 41 + .../src/jobs/services/model-poller.service.ts | 75 + backend/src/main.ts | 115 + backend/src/models/dto/create-model.dto.ts | 92 + backend/src/models/dto/update-model.dto.ts | 4 + backend/src/models/models.controller.test.ts | 89 + backend/src/models/models.controller.ts | 78 + backend/src/models/models.module.ts | 12 + backend/src/models/models.service.test.ts | 111 + backend/src/models/models.service.ts | 199 + .../playlists/dto/add-playlist-item.dto.ts | 30 + .../src/playlists/dto/create-playlist.dto.ts | 51 + .../dto/reorder-playlist-items.dto.ts | 29 + .../playlists/dto/update-playlist-item.dto.ts | 22 + .../src/playlists/dto/update-playlist.dto.ts | 52 + .../playlists/playlists.controller.test.ts | 105 + backend/src/playlists/playlists.controller.ts | 146 + backend/src/playlists/playlists.module.ts | 12 + .../src/playlists/playlists.service.test.ts | 341 + backend/src/playlists/playlists.service.ts | 727 + backend/src/prisma/prisma.module.ts | 12 + backend/src/prisma/prisma.service.ts | 72 + .../screen-designer/dto/assign-device.dto.ts | 11 + .../dto/create-screen-design.dto.ts | 84 + .../screen-designer/dto/create-widget.dto.ts | 87 + backend/src/screen-designer/dto/index.ts | 5 + .../dto/update-screen-design.dto.ts | 4 + .../screen-designer/dto/update-widget.dto.ts | 6 + backend/src/screen-designer/index.ts | 6 + .../screen-designer.controller.ts | 833 ++ .../screen-designer/screen-designer.module.ts | 24 + .../screen-designer.service.ts | 825 ++ .../services/screen-renderer.service.ts | 3158 +++++ .../services/widget-templates.service.ts | 433 + backend/src/screens/dto/create-screen.dto.ts | 56 + backend/src/screens/dto/update-screen.dto.ts | 58 + .../src/screens/screens.controller.test.ts | 96 + backend/src/screens/screens.controller.ts | 80 + backend/src/screens/screens.module.ts | 20 + backend/src/screens/screens.service.test.ts | 139 + backend/src/screens/screens.service.ts | 491 + .../services/image-processor.service.ts | 398 + .../services/welcome-screen.service.ts | 190 + .../src/settings/dto/test-github-token.dto.ts | 9 + .../src/settings/dto/update-setting.dto.ts | 9 + .../src/settings/settings.controller.test.ts | 82 + backend/src/settings/settings.controller.ts | 59 + backend/src/settings/settings.module.ts | 12 + backend/src/settings/settings.service.test.ts | 161 + backend/src/settings/settings.service.ts | 160 + backend/src/test/mocks/helpers.ts | 71 + backend/src/test/mocks/prisma.mock.ts | 45 + backend/tsconfig.json | 26 + backend/uploads/.gitkeep | 0 docker-compose.yml | 30 + docker/cont-init.d/01-postgres-init | 71 + docker/cont-init.d/02-app-init | 8 + docker/nginx.conf | 126 + docker/services.d/backend/run | 32 + docker/services.d/nginx/run | 2 + docker/services.d/postgres/run | 2 + docker/services.d/redis/run | 2 + frontend/.env.example | 3 + frontend/.gitignore | 24 + frontend/bun.lock | 899 ++ frontend/eslint.config.js | 23 + frontend/index.html | 15 + frontend/package-lock.json | 3993 ++++++ frontend/package.json | 54 + frontend/postcss.config.js | 6 + frontend/public/bmc-button.png | Bin 0 -> 4811 bytes frontend/public/favicon.svg | 32 + frontend/public/fonts/Inter-Bold.woff2 | Bin 0 -> 17784 bytes frontend/public/fonts/Inter-Medium.woff2 | Bin 0 -> 17552 bytes frontend/public/fonts/Inter-Regular.woff2 | Bin 0 -> 16708 bytes frontend/public/fonts/Inter-SemiBold.woff2 | Bin 0 -> 17660 bytes frontend/public/fonts/Merriweather-Bold.woff2 | Bin 0 -> 19740 bytes .../public/fonts/Merriweather-Regular.woff2 | Bin 0 -> 20028 bytes frontend/public/fonts/RobotoMono-Bold.woff2 | Bin 0 -> 12276 bytes frontend/public/fonts/RobotoMono-Medium.woff2 | Bin 0 -> 12532 bytes .../public/fonts/RobotoMono-Regular.woff2 | Bin 0 -> 12312 bytes frontend/public/fonts/fonts.css | 74 + frontend/public/logo-light.svg | 10 + frontend/public/logo-wordmark.svg | 8 + frontend/public/logo.svg | 76 + frontend/src/App.tsx | 259 + frontend/src/components/ProtectedRoute.tsx | 29 + .../components/common/BatteryIndicator.tsx | 212 + frontend/src/components/common/Button.tsx | 127 + frontend/src/components/common/Card.tsx | 105 + .../common/CustomWidgetPreviewRenderer.tsx | 359 + frontend/src/components/common/EInkImage.tsx | 195 + .../src/components/common/ErrorBoundary.tsx | 129 + frontend/src/components/common/Input.tsx | 96 + .../src/components/common/LoadingSkeleton.tsx | 130 + .../src/components/common/LoadingSpinner.tsx | 111 + frontend/src/components/common/Modal.tsx | 134 + .../src/components/common/OnlineStatus.tsx | 307 + .../src/components/common/ScriptEditor.tsx | 557 + .../components/common/SearchableSelect.tsx | 277 + frontend/src/components/common/Select.tsx | 75 + .../src/components/common/StatusBadge.tsx | 145 + frontend/src/components/common/Toast.tsx | 121 + .../src/components/common/WifiIndicator.tsx | 253 + frontend/src/components/common/index.ts | 30 + frontend/src/components/layout/Header.tsx | 94 + frontend/src/components/layout/MainLayout.tsx | 31 + frontend/src/components/layout/Sidebar.tsx | 221 + frontend/src/components/layout/index.ts | 4 + .../screen-designer/DesignCanvas.tsx | 859 ++ .../screen-designer/DraggableWidget.tsx | 661 + .../screen-designer/ScreenDesignPreview.tsx | 107 + .../TimezoneSelectionModal.tsx | 270 + .../screen-designer/WidgetLibrary.tsx | 361 + .../screen-designer/WidgetRenderer.tsx | 1533 ++ .../screen-designer/WidgetSettingsPanel.tsx | 2394 ++++ .../src/components/screen-designer/index.ts | 7 + .../src/components/settings/ApiSettings.tsx | 269 + .../components/settings/DeviceConnection.tsx | 399 + .../src/components/settings/EInkPreview.tsx | 78 + .../settings/WelcomeScreenSettings.tsx | 366 + frontend/src/config.test.ts | 48 + frontend/src/config.ts | 80 + frontend/src/contexts/AuthContext.tsx | 184 + .../src/contexts/NotificationContext.test.tsx | 76 + frontend/src/contexts/NotificationContext.tsx | 108 + frontend/src/hooks/index.ts | 4 + frontend/src/hooks/useApi.ts | 295 + frontend/src/hooks/useDeviceUpdates.ts | 189 + frontend/src/hooks/useInfiniteScroll.ts | 164 + frontend/src/hooks/useServerStatus.ts | 120 + frontend/src/index.css | 263 + frontend/src/main.tsx | 10 + frontend/src/pages/Dashboard.tsx | 583 + frontend/src/pages/auth/Login.tsx | 177 + .../pages/custom-widgets/CustomWidgetForm.tsx | 1536 +++ .../custom-widgets/CustomWidgetPreview.tsx | 258 + frontend/src/pages/custom-widgets/index.ts | 2 + .../src/pages/data-sources/DataSourceForm.tsx | 676 + frontend/src/pages/data-sources/index.ts | 1 + frontend/src/pages/devices/DeviceDetail.tsx | 717 + frontend/src/pages/devices/DeviceForm.tsx | 146 + frontend/src/pages/devices/DevicesList.tsx | 465 + frontend/src/pages/extensions/Extensions.tsx | 813 ++ frontend/src/pages/extensions/index.ts | 1 + frontend/src/pages/models/ModelsList.tsx | 59 + .../src/pages/playlists/PlaylistDetail.tsx | 616 + frontend/src/pages/playlists/PlaylistForm.tsx | 568 + .../src/pages/playlists/PlaylistsList.tsx | 333 + frontend/src/pages/screens/ScreenDesigner.tsx | 1004 ++ frontend/src/pages/screens/ScreenDetail.tsx | 327 + frontend/src/pages/screens/ScreenForm.tsx | 217 + frontend/src/pages/screens/ScreensList.tsx | 704 + frontend/src/pages/settings/Settings.tsx | 88 + frontend/src/services/api.test.ts | 133 + frontend/src/services/api.ts | 1080 ++ frontend/src/test/setup.ts | 1 + frontend/src/types/index.ts | 474 + frontend/src/utils/qrcode.ts | 30 + frontend/tailwind.config.js | 93 + frontend/tsconfig.app.json | 29 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 58 + frontend/vitest.config.ts | 12 + 284 files changed, 65639 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/.eslintignore create mode 100644 backend/.gitignore create mode 100644 backend/assets/.gitkeep create mode 100644 backend/assets/default-screen.png create mode 100644 backend/assets/fonts/Inter-Bold.woff2 create mode 100644 backend/assets/fonts/Inter-Medium.woff2 create mode 100644 backend/assets/fonts/Inter-Regular.woff2 create mode 100644 backend/assets/fonts/Inter-SemiBold.woff2 create mode 100644 backend/assets/fonts/Merriweather-Bold.woff2 create mode 100644 backend/assets/fonts/Merriweather-Regular.woff2 create mode 100644 backend/assets/fonts/RobotoMono-Bold.woff2 create mode 100644 backend/assets/fonts/RobotoMono-Medium.woff2 create mode 100644 backend/assets/fonts/RobotoMono-Regular.woff2 create mode 100644 backend/assets/setup.png create mode 100644 backend/assets/test.png create mode 100644 backend/bun.lock create mode 100644 backend/docker-entrypoint.sh create mode 100644 backend/eslint.config.js create mode 100644 backend/nest-cli.json create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20251221184518_remove_user_auth/migration.sql create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/api/api.controller.test.ts create mode 100644 backend/src/api/api.controller.ts create mode 100644 backend/src/api/api.module.ts create mode 100644 backend/src/api/display/default-screen.service.ts create mode 100644 backend/src/api/display/display.service.test.ts create mode 100644 backend/src/api/display/display.service.ts create mode 100644 backend/src/api/log/dto/create-log.dto.ts create mode 100644 backend/src/api/log/log.service.test.ts create mode 100644 backend/src/api/log/log.service.ts create mode 100644 backend/src/api/setup/setup-screen.service.ts create mode 100644 backend/src/api/setup/setup.service.test.ts create mode 100644 backend/src/api/setup/setup.service.ts create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/auth/auth.controller.test.ts create mode 100644 backend/src/auth/auth.controller.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/dto/login.dto.ts create mode 100644 backend/src/auth/guards/pin-auth.guard.test.ts create mode 100644 backend/src/auth/guards/pin-auth.guard.ts create mode 100644 backend/src/auth/pin-auth.service.test.ts create mode 100644 backend/src/auth/pin-auth.service.ts create mode 100644 backend/src/common/common.module.ts create mode 100644 backend/src/common/constants/widget.constants.ts create mode 100644 backend/src/common/decorators/public.decorator.ts create mode 100644 backend/src/common/filters/http-exception.filter.ts create mode 100644 backend/src/common/interceptors/transform.interceptor.ts create mode 100644 backend/src/common/utils/crypto.util.test.ts create mode 100644 backend/src/common/utils/crypto.util.ts create mode 100644 backend/src/common/utils/response.util.test.ts create mode 100644 backend/src/common/utils/response.util.ts create mode 100644 backend/src/common/utils/url-safety.ts create mode 100644 backend/src/config/configuration.ts create mode 100644 backend/src/config/logger.config.ts create mode 100644 backend/src/config/validation.schema.ts create mode 100644 backend/src/custom-widgets/custom-widgets.controller.test.ts create mode 100644 backend/src/custom-widgets/custom-widgets.controller.ts create mode 100644 backend/src/custom-widgets/custom-widgets.module.ts create mode 100644 backend/src/custom-widgets/custom-widgets.service.test.ts create mode 100644 backend/src/custom-widgets/custom-widgets.service.ts create mode 100644 backend/src/custom-widgets/dto/create-custom-widget.dto.ts create mode 100644 backend/src/custom-widgets/dto/update-custom-widget.dto.ts create mode 100644 backend/src/custom-widgets/services/script-executor.service.test.ts create mode 100644 backend/src/custom-widgets/services/script-executor.service.ts create mode 100644 backend/src/dashboard/dashboard.controller.test.ts create mode 100644 backend/src/dashboard/dashboard.controller.ts create mode 100644 backend/src/dashboard/dashboard.module.ts create mode 100644 backend/src/dashboard/dashboard.service.test.ts create mode 100644 backend/src/dashboard/dashboard.service.ts create mode 100644 backend/src/dashboard/dto/dashboard-stats.dto.ts create mode 100644 backend/src/data-sources/data-sources.controller.test.ts create mode 100644 backend/src/data-sources/data-sources.controller.ts create mode 100644 backend/src/data-sources/data-sources.module.ts create mode 100644 backend/src/data-sources/data-sources.service.test.ts create mode 100644 backend/src/data-sources/data-sources.service.ts create mode 100644 backend/src/data-sources/dto/create-data-source.dto.ts create mode 100644 backend/src/data-sources/dto/test-url.dto.ts create mode 100644 backend/src/data-sources/dto/update-data-source.dto.ts create mode 100644 backend/src/devices/devices.controller.test.ts create mode 100644 backend/src/devices/devices.controller.ts create mode 100644 backend/src/devices/devices.module.ts create mode 100644 backend/src/devices/devices.service.test.ts create mode 100644 backend/src/devices/devices.service.ts create mode 100644 backend/src/devices/dto/create-device.dto.ts create mode 100644 backend/src/devices/dto/update-device.dto.ts create mode 100644 backend/src/devices/entities/device.entity.test.ts create mode 100644 backend/src/devices/entities/device.entity.ts create mode 100644 backend/src/events/events.controller.ts create mode 100644 backend/src/events/events.module.ts create mode 100644 backend/src/events/events.service.test.ts create mode 100644 backend/src/events/events.service.ts create mode 100644 backend/src/extensions/dto/create-extension.dto.ts create mode 100644 backend/src/extensions/dto/update-extension.dto.ts create mode 100644 backend/src/extensions/extensions.controller.test.ts create mode 100644 backend/src/extensions/extensions.controller.ts create mode 100644 backend/src/extensions/extensions.module.ts create mode 100644 backend/src/extensions/extensions.service.test.ts create mode 100644 backend/src/extensions/extensions.service.ts create mode 100644 backend/src/firmware/dto/create-firmware.dto.ts create mode 100644 backend/src/firmware/dto/update-firmware.dto.ts create mode 100644 backend/src/firmware/firmware.controller.test.ts create mode 100644 backend/src/firmware/firmware.controller.ts create mode 100644 backend/src/firmware/firmware.module.ts create mode 100644 backend/src/firmware/firmware.service.test.ts create mode 100644 backend/src/firmware/firmware.service.ts create mode 100644 backend/src/health/health.controller.ts create mode 100644 backend/src/health/health.module.ts create mode 100644 backend/src/jobs/jobs.module.ts create mode 100644 backend/src/jobs/processors/log-cleanup.processor.ts create mode 100644 backend/src/jobs/processors/model-poller.processor.ts create mode 100644 backend/src/jobs/services/log-cleanup.service.ts create mode 100644 backend/src/jobs/services/model-poller.service.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/models/dto/create-model.dto.ts create mode 100644 backend/src/models/dto/update-model.dto.ts create mode 100644 backend/src/models/models.controller.test.ts create mode 100644 backend/src/models/models.controller.ts create mode 100644 backend/src/models/models.module.ts create mode 100644 backend/src/models/models.service.test.ts create mode 100644 backend/src/models/models.service.ts create mode 100644 backend/src/playlists/dto/add-playlist-item.dto.ts create mode 100644 backend/src/playlists/dto/create-playlist.dto.ts create mode 100644 backend/src/playlists/dto/reorder-playlist-items.dto.ts create mode 100644 backend/src/playlists/dto/update-playlist-item.dto.ts create mode 100644 backend/src/playlists/dto/update-playlist.dto.ts create mode 100644 backend/src/playlists/playlists.controller.test.ts create mode 100644 backend/src/playlists/playlists.controller.ts create mode 100644 backend/src/playlists/playlists.module.ts create mode 100644 backend/src/playlists/playlists.service.test.ts create mode 100644 backend/src/playlists/playlists.service.ts create mode 100644 backend/src/prisma/prisma.module.ts create mode 100644 backend/src/prisma/prisma.service.ts create mode 100644 backend/src/screen-designer/dto/assign-device.dto.ts create mode 100644 backend/src/screen-designer/dto/create-screen-design.dto.ts create mode 100644 backend/src/screen-designer/dto/create-widget.dto.ts create mode 100644 backend/src/screen-designer/dto/index.ts create mode 100644 backend/src/screen-designer/dto/update-screen-design.dto.ts create mode 100644 backend/src/screen-designer/dto/update-widget.dto.ts create mode 100644 backend/src/screen-designer/index.ts create mode 100644 backend/src/screen-designer/screen-designer.controller.ts create mode 100644 backend/src/screen-designer/screen-designer.module.ts create mode 100644 backend/src/screen-designer/screen-designer.service.ts create mode 100644 backend/src/screen-designer/services/screen-renderer.service.ts create mode 100644 backend/src/screen-designer/services/widget-templates.service.ts create mode 100644 backend/src/screens/dto/create-screen.dto.ts create mode 100644 backend/src/screens/dto/update-screen.dto.ts create mode 100644 backend/src/screens/screens.controller.test.ts create mode 100644 backend/src/screens/screens.controller.ts create mode 100644 backend/src/screens/screens.module.ts create mode 100644 backend/src/screens/screens.service.test.ts create mode 100644 backend/src/screens/screens.service.ts create mode 100644 backend/src/screens/services/image-processor.service.ts create mode 100644 backend/src/screens/services/welcome-screen.service.ts create mode 100644 backend/src/settings/dto/test-github-token.dto.ts create mode 100644 backend/src/settings/dto/update-setting.dto.ts create mode 100644 backend/src/settings/settings.controller.test.ts create mode 100644 backend/src/settings/settings.controller.ts create mode 100644 backend/src/settings/settings.module.ts create mode 100644 backend/src/settings/settings.service.test.ts create mode 100644 backend/src/settings/settings.service.ts create mode 100644 backend/src/test/mocks/helpers.ts create mode 100644 backend/src/test/mocks/prisma.mock.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/uploads/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docker/cont-init.d/01-postgres-init create mode 100644 docker/cont-init.d/02-app-init create mode 100644 docker/nginx.conf create mode 100644 docker/services.d/backend/run create mode 100644 docker/services.d/nginx/run create mode 100644 docker/services.d/postgres/run create mode 100644 docker/services.d/redis/run create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/bun.lock create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/bmc-button.png create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/fonts/Inter-Bold.woff2 create mode 100644 frontend/public/fonts/Inter-Medium.woff2 create mode 100644 frontend/public/fonts/Inter-Regular.woff2 create mode 100644 frontend/public/fonts/Inter-SemiBold.woff2 create mode 100644 frontend/public/fonts/Merriweather-Bold.woff2 create mode 100644 frontend/public/fonts/Merriweather-Regular.woff2 create mode 100644 frontend/public/fonts/RobotoMono-Bold.woff2 create mode 100644 frontend/public/fonts/RobotoMono-Medium.woff2 create mode 100644 frontend/public/fonts/RobotoMono-Regular.woff2 create mode 100644 frontend/public/fonts/fonts.css create mode 100644 frontend/public/logo-light.svg create mode 100644 frontend/public/logo-wordmark.svg create mode 100644 frontend/public/logo.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/common/BatteryIndicator.tsx create mode 100644 frontend/src/components/common/Button.tsx create mode 100644 frontend/src/components/common/Card.tsx create mode 100644 frontend/src/components/common/CustomWidgetPreviewRenderer.tsx create mode 100644 frontend/src/components/common/EInkImage.tsx create mode 100644 frontend/src/components/common/ErrorBoundary.tsx create mode 100644 frontend/src/components/common/Input.tsx create mode 100644 frontend/src/components/common/LoadingSkeleton.tsx create mode 100644 frontend/src/components/common/LoadingSpinner.tsx create mode 100644 frontend/src/components/common/Modal.tsx create mode 100644 frontend/src/components/common/OnlineStatus.tsx create mode 100644 frontend/src/components/common/ScriptEditor.tsx create mode 100644 frontend/src/components/common/SearchableSelect.tsx create mode 100644 frontend/src/components/common/Select.tsx create mode 100644 frontend/src/components/common/StatusBadge.tsx create mode 100644 frontend/src/components/common/Toast.tsx create mode 100644 frontend/src/components/common/WifiIndicator.tsx create mode 100644 frontend/src/components/common/index.ts create mode 100644 frontend/src/components/layout/Header.tsx create mode 100644 frontend/src/components/layout/MainLayout.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/index.ts create mode 100644 frontend/src/components/screen-designer/DesignCanvas.tsx create mode 100644 frontend/src/components/screen-designer/DraggableWidget.tsx create mode 100644 frontend/src/components/screen-designer/ScreenDesignPreview.tsx create mode 100644 frontend/src/components/screen-designer/TimezoneSelectionModal.tsx create mode 100644 frontend/src/components/screen-designer/WidgetLibrary.tsx create mode 100644 frontend/src/components/screen-designer/WidgetRenderer.tsx create mode 100644 frontend/src/components/screen-designer/WidgetSettingsPanel.tsx create mode 100644 frontend/src/components/screen-designer/index.ts create mode 100644 frontend/src/components/settings/ApiSettings.tsx create mode 100644 frontend/src/components/settings/DeviceConnection.tsx create mode 100644 frontend/src/components/settings/EInkPreview.tsx create mode 100644 frontend/src/components/settings/WelcomeScreenSettings.tsx create mode 100644 frontend/src/config.test.ts create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/contexts/NotificationContext.test.tsx create mode 100644 frontend/src/contexts/NotificationContext.tsx create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/hooks/useApi.ts create mode 100644 frontend/src/hooks/useDeviceUpdates.ts create mode 100644 frontend/src/hooks/useInfiniteScroll.ts create mode 100644 frontend/src/hooks/useServerStatus.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/auth/Login.tsx create mode 100644 frontend/src/pages/custom-widgets/CustomWidgetForm.tsx create mode 100644 frontend/src/pages/custom-widgets/CustomWidgetPreview.tsx create mode 100644 frontend/src/pages/custom-widgets/index.ts create mode 100644 frontend/src/pages/data-sources/DataSourceForm.tsx create mode 100644 frontend/src/pages/data-sources/index.ts create mode 100644 frontend/src/pages/devices/DeviceDetail.tsx create mode 100644 frontend/src/pages/devices/DeviceForm.tsx create mode 100644 frontend/src/pages/devices/DevicesList.tsx create mode 100644 frontend/src/pages/extensions/Extensions.tsx create mode 100644 frontend/src/pages/extensions/index.ts create mode 100644 frontend/src/pages/models/ModelsList.tsx create mode 100644 frontend/src/pages/playlists/PlaylistDetail.tsx create mode 100644 frontend/src/pages/playlists/PlaylistForm.tsx create mode 100644 frontend/src/pages/playlists/PlaylistsList.tsx create mode 100644 frontend/src/pages/screens/ScreenDesigner.tsx create mode 100644 frontend/src/pages/screens/ScreenDetail.tsx create mode 100644 frontend/src/pages/screens/ScreenForm.tsx create mode 100644 frontend/src/pages/screens/ScreensList.tsx create mode 100644 frontend/src/pages/settings/Settings.tsx create mode 100644 frontend/src/services/api.test.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/qrcode.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2771137 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Inker Configuration +# Copy this file to .env and modify as needed: +# cp .env.example .env + +# Admin PIN for login (change this!) +ADMIN_PIN="1111" + +# Database password (change for production!) +POSTGRES_PASSWORD=inker_password + +# Timezone for clock/date widgets +TZ=UTC + +# Frontend port (default: 80) +INKER_PORT=80 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1ff163 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +dist/ +build/ +.next/ +out/ + +# Environment +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +logs.txt +npm-debug.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docker volumes (local data) +postgres_data/ +redis_data/ + +# Docker local overrides +docker-compose.override.yml + +# Uploads (user generated) +uploads/* +!uploads/.gitkeep + +# Package locks (optional - keep if you want reproducible builds) +# package-lock.json +# bun.lockb + +# Test coverage +coverage/ +.nyc_output/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36d3cbe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,217 @@ +# All-in-one Dockerfile for Inker +# Bundles: frontend (nginx), backend (bun/nestjs), PostgreSQL 17, Redis 8 + +# ============================================================================= +# Stage 1: Build frontend +# ============================================================================= +FROM oven/bun:1-alpine AS frontend-builder + +WORKDIR /app + +COPY frontend/package.json frontend/bun.lock* ./ +RUN bun install --frozen-lockfile + +COPY frontend/ . +RUN bun run build + +# ============================================================================= +# Stage 2: Install backend production dependencies +# ============================================================================= +FROM oven/bun:1-slim AS backend-install + +WORKDIR /app + +# Node.js binary for Prisma generate (bun segfaults with Prisma CLI) +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node + +RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/* + +COPY backend/package.json backend/bun.lock* ./ +COPY backend/prisma ./prisma/ + +# Install all deps → generate prisma → reinstall production-only → prune +RUN bun install --frozen-lockfile && \ + node ./node_modules/prisma/build/index.js generate && \ + cp -r node_modules/.prisma /tmp/.prisma && \ + rm -rf node_modules && \ + bun install --production --frozen-lockfile && \ + cp -r /tmp/.prisma node_modules/.prisma && \ + rm -rf /tmp/.prisma \ + node_modules/typescript \ + node_modules/@types && \ + # Prune unnecessary files from production node_modules + find node_modules \( \ + -name "*.md" -o -name "*.map" -o -name "CHANGELOG*" -o \ + -name "README*" -o -name "LICENSE*" -o -name "*.d.ts" -o \ + -name "*.test.*" -o -name "*.spec.*" -o \ + -name "__tests__" -o -name "docs" -o -name ".github" -o \ + -name "example" -o -name "examples" -o -name ".npmignore" -o \ + -name "tsconfig.json" -o -name ".eslintrc*" -o -name ".prettierrc*" \ + \) -exec rm -rf {} + 2>/dev/null || true && \ + # Remove swagger UI (not needed in production) + rm -rf node_modules/swagger-ui-dist && \ + # Remove musl variants of sharp (only glibc needed on Debian) + rm -rf node_modules/@img/sharp-libvips-linuxmusl-x64 \ + node_modules/@img/sharp-linuxmusl-x64 + +# ============================================================================= +# Stage 3: Build backend +# ============================================================================= +FROM oven/bun:1-slim AS backend-builder + +WORKDIR /app + +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node + +RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/* + +COPY backend/package.json backend/bun.lock* ./ +RUN bun install --frozen-lockfile + +COPY backend/prisma ./prisma/ +RUN node ./node_modules/prisma/build/index.js generate + +COPY backend/ . +RUN bun run build + +# ============================================================================= +# Stage 4: Production (all-in-one) +# ============================================================================= +FROM debian:trixie-slim AS production + +ARG S6_OVERLAY_VERSION=3.2.1.0 + +# Install all system packages in one layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + # PostgreSQL + postgresql-17 \ + # Redis + redis-server \ + # Nginx + nginx \ + # Chrome headless shell dependencies + wget ca-certificates openssl unzip \ + fonts-liberation fontconfig \ + libnss3 libatk-bridge2.0-0t64 libdrm2 libxkbcommon0 \ + libgbm1 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libasound2t64 libcups2t64 libatk1.0-0t64 libnspr4 libdbus-1-3 \ + # s6-overlay dependencies + xz-utils \ + && \ + # Install Chrome Headless Shell + CHROME_VERSION=$(wget -qO- "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE") && \ + wget -q "https://storage.googleapis.com/chrome-for-testing-public/${CHROME_VERSION}/linux64/chrome-headless-shell-linux64.zip" -O /tmp/chrome.zip && \ + unzip /tmp/chrome.zip -d /opt/ && \ + chmod +x /opt/chrome-headless-shell-linux64/chrome-headless-shell && \ + rm /tmp/chrome.zip && \ + # Strip Chrome: remove GPU libs (--disable-gpu), keep only en-US locale, remove hyphen data + rm -f /opt/chrome-headless-shell-linux64/libEGL.so \ + /opt/chrome-headless-shell-linux64/libGLESv2.so \ + /opt/chrome-headless-shell-linux64/libvk_swiftshader.so \ + /opt/chrome-headless-shell-linux64/libvulkan.so.1 \ + /opt/chrome-headless-shell-linux64/vk_swiftshader_icd.json \ + /opt/chrome-headless-shell-linux64/LICENSE.headless_shell && \ + find /opt/chrome-headless-shell-linux64/locales -type f ! -name 'en-US.pak' -delete && \ + rm -rf /opt/chrome-headless-shell-linux64/hyphen-data && \ + # Install s6-overlay + wget -q "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" -O /tmp/s6-noarch.tar.xz && \ + tar -C / -Jxpf /tmp/s6-noarch.tar.xz && \ + wget -q "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz" -O /tmp/s6-x86_64.tar.xz && \ + tar -C / -Jxpf /tmp/s6-x86_64.tar.xz && \ + rm /tmp/s6-noarch.tar.xz /tmp/s6-x86_64.tar.xz && \ + # Install PostgreSQL 15 from PGDG for data migration (15→17) support + # Existing users upgrading from bookworm need PG 15 binaries to dump their data + echo "deb http://apt.postgresql.org/pub/repos/apt trixie-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ + wget -qO /etc/apt/trusted.gpg.d/pgdg.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc && \ + apt-get update -qq && \ + apt-get install -y -qq --no-install-recommends postgresql-15 >/dev/null 2>&1 && \ + rm -rf /var/lib/postgresql/15 /etc/apt/sources.list.d/pgdg.list && \ + # Remove build-only tools normally + apt-get purge -y unzip xz-utils wget && apt-get autoremove -y && \ + # Force-remove transitive deps not needed at runtime + # (bypass dependency checks to avoid cascading removal of postgresql/chrome) + dpkg --purge --force-depends \ + libllvm19 libz3-4 libperl5.40 perl perl-modules-5.40 \ + libavahi-client3 libavahi-common-data libavahi-common3 \ + libelf1t64 libc-l10n locales \ + 2>/dev/null || true && \ + rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt && \ + # Remove system locales and unused data (keep only C/POSIX and en_US) + rm -rf /usr/share/locale /usr/share/i18n /usr/share/doc /usr/share/man \ + /usr/share/info /usr/share/lintian /usr/share/X11/xkb \ + /var/cache/debconf/*-old && \ + # Remove auto-created PostgreSQL cluster (will be initialized on first run) + rm -rf /var/lib/postgresql/17/main/* + +# Install Bun runtime (copy from build image) +COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun +RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx + +# Puppeteer configuration +ENV PUPPETEER_EXECUTABLE_PATH=/opt/chrome-headless-shell-linux64/chrome-headless-shell +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# Application environment defaults +ENV NODE_ENV=production \ + PORT=3002 \ + DATABASE_URL=postgresql://inker_user:inker_password@localhost:5432/inker_db \ + REDIS_HOST=localhost \ + REDIS_PORT=6379 \ + REDIS_PASSWORD=inker_redis \ + REDIS_URL=redis://:inker_redis@localhost:6379 \ + ADMIN_PIN="1111" \ + CORS_ORIGINS=* \ + LOG_LEVEL=info + +# Set up application directory +WORKDIR /app + +# Copy backend production dependencies +COPY --from=backend-install /app/node_modules ./node_modules + +# Copy Prisma schema and generated client +COPY backend/prisma ./prisma/ +COPY --from=backend-install /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=backend-install /app/node_modules/@prisma ./node_modules/@prisma + +# Copy backend build +COPY --from=backend-builder /app/dist ./dist +COPY backend/package.json ./ + +# Copy backend font assets +COPY backend/assets/fonts /app/assets/fonts + +# Copy frontend build to nginx html directory +COPY --from=frontend-builder /app/dist /usr/share/nginx/html + +# Copy frontend font files +COPY frontend/public/fonts /usr/share/nginx/html/fonts + +# Copy nginx config +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +RUN rm -f /etc/nginx/sites-enabled/default + +# Copy s6-overlay service definitions +COPY docker/cont-init.d/ /etc/cont-init.d/ +COPY docker/services.d/ /etc/services.d/ +RUN chmod +x /etc/cont-init.d/* && \ + chmod +x /etc/services.d/*/run + +# Create required directories +RUN mkdir -p /app/uploads/screens /app/uploads/firmware /app/uploads/widgets \ + /app/uploads/captures /app/uploads/drawings /app/logs \ + /data /var/lib/postgresql/17/main /run/postgresql && \ + chown -R postgres:postgres /var/lib/postgresql /run/postgresql + +# Create non-root user for backend process +RUN useradd --system --no-create-home --shell /usr/sbin/nologin inker && \ + chown -R inker:inker /app + +EXPOSE 80 + +# Health check via nginx +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD bun -e "const r=await fetch('http://127.0.0.1/health');process.exit(r.ok?0:1)" || exit 1 + +# s6-overlay entrypoint +ENTRYPOINT ["/init"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..002920a --- /dev/null +++ b/LICENSE @@ -0,0 +1,172 @@ +Inker - Source Available License +License Version 1.1 + +Copyright (c) 2025-2026 Wojtek (wojo) +All rights reserved. + + +DEFINITIONS + +"Software" means the Inker source code, documentation, assets, and any +associated files in this repository. + +"Personal Use" means use by an individual for private, non-commercial +purposes where the Software does not directly or indirectly generate +revenue. + +"Internal Organizational Use" means use within an organization solely for +that organization's own operational purposes, provided the Software is not +offered to third parties and does not form a component of any product or +service provided to third parties, whether for a fee or otherwise. + +"Contribution" means any modification, addition, or improvement submitted +to the original repository via pull request, patch, or other mechanism. + +"Original Repository" means the official Inker repository maintained by the +copyright holder at its designated hosting location. + + +1. GRANT OF LICENSE + + Subject to the terms and conditions of this license, the copyright holder + grants you a limited, non-exclusive, non-transferable, non-sublicensable, + revocable license to: + + (a) View, download, and run the Software for Personal Use. + (b) Run the Software for Internal Organizational Use. + (c) Submit Contributions to the Original Repository. + + No other rights are granted, whether express or implied. All rights not + explicitly granted herein are reserved by the copyright holder. + + +2. RESTRICTIONS + + You may NOT, and you may not permit any third party to: + + (a) Sell, resell, sublicense, rent, lease, loan, or otherwise transfer + the Software or any rights in the Software to any third party. + + (b) Redistribute, publish, or make the Software available to third + parties in any form, whether modified or unmodified, in whole or + in part. + + (c) Modify, adapt, translate, reverse engineer, decompile, disassemble, + or create derivative works based on the Software, except for + Contributions submitted to the Original Repository in accordance + with Section 3. + + (d) Use the Software, in whole or in part, as a component of, or + foundation for, any product or service offered to third parties, + whether for a fee or free of charge. This includes, without + limitation, offering the Software as a hosted service, a managed + service, or embedding it within another product. + + (e) Use the Software to develop any software or service that provides + substantially similar functionality to the Software. + + (f) Remove, alter, or obscure this license notice, any copyright + notices, or any other proprietary notices contained in the + Software. + + (g) Use the name, trademarks, or branding of the Software or its + author to endorse or promote products or services without prior + written permission. + + (h) Circumvent or attempt to circumvent any of the restrictions in + this license, including by using the Software's design, structure, + or logic to create a substantially similar work in any programming + language or framework. + + +3. CONTRIBUTIONS + + By submitting a Contribution to the Original Repository, you: + + (a) Grant the copyright holder a perpetual, worldwide, irrevocable, + non-exclusive, royalty-free, fully sublicensable license to use, + reproduce, modify, display, distribute, and create derivative works + from your Contribution as part of the Software, under any license + terms the copyright holder chooses. + + (b) Represent that you have the legal right to grant the above license + and that your Contribution does not infringe on any third-party + rights. + + (c) Acknowledge that the creation of a Contribution for submission to + the Original Repository is the sole exception to the restriction + on derivative works in Section 2(c). + + +4. TERMINATION + + (a) This license is effective until terminated. Your rights under this + license will terminate automatically and immediately, without notice, + if you fail to comply with any term of this license. + + (b) Upon termination, you must cease all use of the Software and destroy + all copies in your possession or control. + + (c) The copyright holder reserves the right to revoke this license at + any time, for any reason, upon thirty (30) days written notice. + + (d) Sections 5, 6, 7, and 8 survive termination of this license. + + +5. DISCLAIMER OF WARRANTIES + + THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT WARRANTY + OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, + NON-INFRINGEMENT, AND ACCURACY. THE COPYRIGHT HOLDER DOES NOT WARRANT + THAT THE SOFTWARE WILL BE ERROR-FREE, UNINTERRUPTED, OR FREE OF + HARMFUL COMPONENTS. + + +6. LIMITATION OF LIABILITY + + TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL + THE COPYRIGHT HOLDER BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, + CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS + OF PROFITS, LOSS OF DATA, BUSINESS INTERRUPTION, OR LOSS OF GOODWILL, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR INABILITY TO USE THE + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + + THE COPYRIGHT HOLDER'S TOTAL AGGREGATE LIABILITY ARISING OUT OF OR + RELATED TO THIS LICENSE SHALL NOT EXCEED THE AMOUNT YOU PAID FOR THE + SOFTWARE (WHICH IS ZERO). + + SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN + DAMAGES. IN SUCH JURISDICTIONS, THE ABOVE LIMITATIONS SHALL APPLY TO + THE MAXIMUM EXTENT PERMITTED BY LAW. + + +7. GOVERNING LAW AND DISPUTE RESOLUTION + + (a) This license shall be governed by and construed in accordance with + the laws of the Republic of Poland, without regard to its conflict + of law provisions. + + (b) Any dispute arising out of or in connection with this license shall + be subject to the exclusive jurisdiction of the courts of Poland. + + (c) Nothing in this section shall prevent the copyright holder from + seeking injunctive or other equitable relief in any jurisdiction + to protect intellectual property rights. + + +8. GENERAL PROVISIONS + + (a) This license constitutes the entire agreement between you and the + copyright holder regarding the Software and supersedes all prior + agreements and understandings. + + (b) If any provision of this license is held to be unenforceable, the + remaining provisions shall remain in full force and effect. + + (c) The failure of the copyright holder to enforce any right or + provision of this license shall not constitute a waiver of such + right or provision. + + (d) This license may not be assigned or transferred by you without the + prior written consent of the copyright holder. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2637e6 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://buymeacoffee.com/wojo_o) + +# Inker v0.1.0 + +Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) devices and devices compatible with TRMNL 1.6.9 software. Design screens, create custom widgets, and manage your displays from a modern web interface. + +![Dashboard](https://github.com/user-attachments/assets/fd9affac-5c57-4448-9338-ea8f83add08a) + +## Features + +- **Screen Designer** — Drag & drop widget placement, snap guides, freehand drawing, export/import +- **Built-in Widgets** — Clock, date, text, weather, countdown, days until, QR code, image, GitHub stars, battery, WiFi, device info +- **Custom Widgets** — Connect to any JSON API or RSS feed, field extraction, JavaScript transformations, grid layouts +- **Playlists** — Rotate multiple screens on devices automatically +- **Device Management** — Auto-provisioning, real-time status, configurable refresh rates, logs + +## Screenshots + +| Screen Designer | Devices | Screens | +|:-:|:-:|:-:| +| ![Devices](https://github.com/user-attachments/assets/e6ba89e7-7bac-419e-bb2e-54a1c0350e07) | ![Screens](https://github.com/user-attachments/assets/510c7d5c-730a-457d-af7d-50ee04b2dc43) | ![Screen Designer](https://github.com/user-attachments/assets/0e4fb32a-bde5-475f-8800-49b06cfce2e9) | + +| List of sources | Custom Data Sources | Custom Widgets | +|:-:|:-:|:-:| +| ![Extensions](https://github.com/user-attachments/assets/534b5104-8f1c-4a42-8c58-f2cef74dbc92) | ![Custom data sources](https://github.com/user-attachments/assets/03ed0dc8-7ae0-44fa-ace7-890b5ec8f385) | ![Custom widgets](https://github.com/user-attachments/assets/0eb10812-568a-46db-b58e-7e82c19ea403) | + +## Quick start + +### Docker Run + +```bash +docker run -d \ + --name inker \ + --restart unless-stopped \ + -p 80:80 \ + -v inker_postgres:/var/lib/postgresql/17/main \ + -v inker_redis:/data \ + -v inker_uploads:/app/uploads \ + wojooo/inker:latest +``` + +### Docker Compose + +```yaml +# docker-compose.yml +services: + inker: + image: wojooo/inker:latest + container_name: inker + restart: unless-stopped + ports: + - "80:80" + volumes: + - postgres_data:/var/lib/postgresql/17/main + - redis_data:/data + - uploads_data:/app/uploads + environment: + TZ: UTC + ADMIN_PIN: "1111" # Quotes required — YAML strips leading zeros without them + +volumes: + postgres_data: + redis_data: + uploads_data: +``` + +```bash +docker compose up -d +``` + +Open **http://your-server-ip** and log in with PIN `1111`. + +## Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `ADMIN_PIN` | Login PIN | `1111` | +| `TZ` | Timezone for widgets | `UTC` | + +Pass with `-e`: +```bash +docker run -d \ + --name inker \ + --restart unless-stopped \ + -p 80:80 \ + -e ADMIN_PIN="1111" \ + -e TZ=Europe/Warsaw \ + -v inker_postgres:/var/lib/postgresql/17/main \ + -v inker_redis:/data \ + -v inker_uploads:/app/uploads \ + wojooo/inker:latest +``` + +### Build from source + +```bash +git clone https://github.com/wojo-o/inker.git +cd inker +docker compose up -d --build +``` + +## Troubleshooting + +If something isn't working after an update or on first run, reset the volumes and start fresh: + +```bash +docker compose down -v +docker compose up -d +``` + +> **Note:** This removes all data (database, uploads). Only use on a fresh install or when you don't mind losing data. + +## Testing + +```bash +cd backend && bun test # 357 tests +cd frontend && bun run test # 19 tests +``` + +## License + +Source Available — see [LICENSE](LICENSE) for details. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7d45ebf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,36 @@ +# Environment +NODE_ENV=development + +# Server +PORT=3002 + +# Database +DATABASE_URL="postgresql://username:password@localhost:5432/inker_db?schema=public" + +# Redis Configuration (optional - for Bull queues) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# CORS Configuration +CORS_ORIGINS=http://localhost:3002,http://localhost:80 + +# Rate Limiting +THROTTLE_TTL=60 +THROTTLE_LIMIT=100 + +# File Upload +MAX_FILE_SIZE=10485760 +SCREENS_DIR=./uploads/screens +FIRMWARE_DIR=./uploads/firmware + +# Device Configuration +DEVICE_POLLING_INTERVAL=60000 +DEVICE_OFFLINE_THRESHOLD=300000 + +# Admin PIN +ADMIN_PIN=1111 + +# Logging +LOG_LEVEL=info +LOG_FORMAT=json diff --git a/backend/.eslintignore b/backend/.eslintignore new file mode 100644 index 0000000..7215d1b --- /dev/null +++ b/backend/.eslintignore @@ -0,0 +1,6 @@ +dist +node_modules +coverage +*.js +.env +uploads diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..d95201f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules +/.pnp +.pnp.js + +# Testing +/coverage +*.log + +# Production +/dist +/build + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Uploads +uploads/* +!uploads/.gitkeep + +# Prisma +prisma/*.db +prisma/*.db-journal + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Misc +.eslintcache +.nyc_output diff --git a/backend/assets/.gitkeep b/backend/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/assets/default-screen.png b/backend/assets/default-screen.png new file mode 100644 index 0000000000000000000000000000000000000000..aeed0b03b5d9f48a6a4db9487a2d46f83c99428d GIT binary patch literal 12144 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

YfClfRfzLS^&Q-Kb7T3f%;kN|CuEAx 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 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Inter-Bold.woff2 b/backend/assets/fonts/Inter-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8fcc4321c7ef938a64db296f2ce380c632e6c06f GIT binary patch literal 17784 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 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Inter-SemiBold.woff2 b/backend/assets/fonts/Inter-SemiBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bc76d107f6f5160ab655aa1de9fd2e7da31ef3e7 GIT binary patch literal 17660 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 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Merriweather-Bold.woff2 b/backend/assets/fonts/Merriweather-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a6919a9da3c168b2bf8dfbc8555c0018860a38a0 GIT binary patch literal 19740 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

*=u73xPNb~d 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 y8PnegeY;c6l$rbg_o*=M@2 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/RobotoMono-Bold.woff2 b/backend/assets/fonts/RobotoMono-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d7170daca1a38b071f13c03f47611f92bb8e0e1a GIT binary patch literal 12276 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 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/RobotoMono-Medium.woff2 b/backend/assets/fonts/RobotoMono-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b4f2bf8c22481b36ce7f9079d40155fa0e31ff42 GIT binary patch literal 12532 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 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/RobotoMono-Regular.woff2 b/backend/assets/fonts/RobotoMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f8894bab50f641e86f695e85830a74dd395732fa GIT binary patch literal 12312 zcmXT-cQayOWME)mFpyy21JU7g7#Q^TLBv3OR4il0mS7(o;uzw=&|)UY!PPi3;io8f zvl9cO8IuZgvlEK~Yby)80uQ_NbeGLaH!K($7WL|O743Z4^xwJ3#(CCTF8u<%=x;uq zYgaMISoF#Ycwg1t`sA^p__@ose#>jmi%~gp=4nvh>E1p5R#A;H|H5PzZuBo&xGM4W z&5|WAf`r%>7F|$qRFd)V)$5)HeGq=XT4N)-@>V>26x3+sycYx&8jRs0FOko=;?-bC2_%<+HsPcWjDWX1x8D z&>qL?72lapaZF`y6;aV+U${8%{W>wX>g~5KFXTIsD(k%~dGfcyc(py}c|@39o7nFP zzvAKX{B)6hVe^G^hfnj!+;kMPI+<&fR&?fg?NOHCw=x1*XRT%{MNXU^qg~V(XB%@Y z?0xB;&X!Bkir=%87(?Yk1@klshYyDt>ssHi&bY|`%%1VJjQbq!qIY+bk2EBIJk-DM-Fvo%Ol7e>3<|T) zvnb5Syb^q(Pmn41(#n=FtL%40+KLbUUl(HD$joy>Y>&oVM;V2^4Axu|3>W(?Z(z}~ z^IpiA+CFEeq|v6{!%kLA7Y?}eY}apfo2$kxuwCfQioZ$aZ~rw(E|NFwnBi2RR3qrY z!Y0_3pv2PQbwtlz<&q@l-1r&t8{b49P7qL1TNe`b^%z%ZO?tPQ;5&}@+)9cP$@NSQ zj4$Sgbeto-rA1s~~en~!>d@cD_^0UVu%uaof_?m2PaV-}>_5CvNTOMV0C6OVeYXajffZd9>tf_VwHsuc!0mNXgu? zFf-a!SyA(Q+FI-FSKrN;H)GDNr5%Rv=H0a3?Y`^%g@4TK{W4}}U1k5iyzwzu{gvOp zqF-mu?|1vp=9reo@Pg?A{|Dw})d%iqKHyK7?syVm{*xrq=J?GhUeOLRMq$vLNJI=nO{$N{i)cR?f*PYIOJNxUTarcjv=?4>*ADp7}WZ}yT9&hFtJ^FXV#O-3c z%K1sheJx#^j9vts($ZF3d%Ebi6K8y96zjC@mp{IXSrpu~` zbjF5r^QP{SGF;J|`2N9pn+ts6TP88Fc6$VdJver&$aRuty3W%DN97NRD;X3ouKu%2 z<=hRkpAUEK*R`+s#`m3ok=tAM#M;z7)3P~92L=)?p>pjKX2lxw8>I(JmDLUo_k$czRTQd zo28xix~?BtSqiR}w;UE|cID=VE)w9sz^}9U?Y=WnEn3U=&#iiQB752Ixig$(qc?C) zoS`^DO-5)&?D>4xqPKf2qF%82*&R5@ke~cvPHAt@8tDwZ&VD5hxj=b0R zj-36q{P)J1L`J!55tA9cRy=?C!oG5O=NU2cqu=vVpY8sVy~+NW$LCUxsT)Ln&mZXi z99tM??jxlV@GkgZ=Sv%fATP&CTNwlUZ#B~r4EN5+3@FWG?kIU_b=qR3)Z;VdQ*JB% z+j=`%O!^8lgGEM6iObZ;9QG78TYgWS`ee)FpC_v&UU~1ocg}I`S+@?|tF|x?@e5Vm z+q;VY-o_2z+RJ(sCM$6=%wT*K+o)EZnq~UZcFV2uNA8?=)~?NEIV7f=(YQ5QEx%p& z{;M6A+63$Ee^#C@*zbRtPkMFf)pS+&`}>^|I<6S2u6ncO7T+-;1&>w#+4Qey7_GWF zL1@38&y9h9<$9w`E0v=? zXXduI9of9Gt9^Ha<5ZosiacqTdJCO7qSRz#UL^9#-K*WUur@?O>1k^>-?`}ROZR*d zt=V3FFnKTQ%8*Ak2W}~N=R55$ZHnA5DZKT7bNKznjlK4478#qq`!+9hV->d z+zHzy-QGKd+gqDo`MjN(FL#d^50k(h50AJ6-WQsGp6&m+NMw(mZ-qzWg(d8J!v%zQ zaIbP|)k)f3yFx*jGh=pR%@dZCJzZyA@})U`xF6bVE$~ID)A3Jcuwe0YJDr49j}&=f zEh_=u2`+0Ao`-xkdVKIqxJ^)NN3e%i3wz79MLJ3?_TS|h_RnlomOQrSy-V+-7S^7i z`8PPuJe!-u$65E-_Lgp2{PMXx%f7p8)hf@s@gqe{Xld)Y6@l+do+xmx*rgk%Y_RiH z{drG=&(j{sS{G%nmpmTc;p{E9)Nam&sNY)CnpL@vt<@JQb1h0=!}2=RUv8bVp#2J& z1&<#@B(D1D?=8_;EmM?IU@CI%*tbO6vmW+rQzqtYX)A3F3hy{~=Z&|h!G|ix>5F-M zqBHL7+xqBw0UQ6SD}0PsKhMv2+Z#CZWYxChvfT{q3*$fcY8dCd>8NIX$1E6;+>&VY z;QAzfroIDR%BKIbH%gRj-wsMXWHI6FEyBZFIjbsnTdki^($XZZ>M@F z{o_t`NU1wE<7+1S-47S6ZKa&5Y(9j>R=@C@=)|FaRYKL+MaC<$^U@|EaXqoV7q3p* z{gVt8tq|I@>es>^_M`vo4lkdV7kOBsV5LXGlB0_&zF22XHb{0o-lS$$c0##g{eu*_ z&bQX-bzW*&8`oWFPI=8O$*$r0=*fOZ<&ZTl+FFwOCzjR~755%CzI5MVz0#D^yOtci zT$s{cJWERN_IKvvj0dc3OV~@K%Qq%-_6Ztw{LHXr_q&QK~My=q&hX==ETvY60 z*X=Ct4EJ=YvHEe)OP+uBk(<30rETZ7FKXjh;I`p@m74s+UAN?K+T=VqalS{Q#^uSz z79l^*kl&U9uZ5U?EEUX26u23x!KlE!$~`09g5j=Wf9y0Ho>wh-LAz9X*eCJ6NlSjU znUQ(#$ICXTao{UqpMn6M3E-G2*TkS4i z6j56gmGxlm$>^8J`(_{~F(s8t>1_F?v^K@ZT1%X5K&Fztner zDbMkOo4?l7-pUl&VDPpkDdz8Q--g8xI11jZd+~n8J4v<=QKj{+8LIIUcPV!jh9_&V zOz2*~dHvVkkO$l`b{m^7DjF`={LR(OWGP`*k||Q|lp(DV8O9lXG2ma^?yr2i_R2lJ z^!){Y;vYWd$@eqvAItKXe4xbirjN(W=~5faJMJudf8*o~qc>YZlV@Kwt=cvBYFJ0< z2i{v8Srx0QudTUzT4n>w=>zYst}f$T$*`!AtFf;@Ggwq-nrT8K<0$*j|T!v72po@>hgXU`_FZ}nZL zXx2&2Tl92onN;H^fBuy+C&tnE70(W}=1H;F?Br7?-DuN$@Ki9vx|}h{VBVTuzX0CH z4ySrm6nQVRIZ4ZHZH;11dAdd3vuxY1^=3hw+L5W#mKy}FlCBWAFi)-KlI+u@5GyhlJ8Xv^6liWX1AZ+o18K2fM@ONLJ^f% zha907nuf1dB)m2Fy>PnT64|*2U%NJ^E_wI)$HD_kYfRrg{(In1HtQp0=DL^a%#KPo z9Jf`4CkZ@#!a-Q=TtA+V(y#d{@hA7p@-1Uz%!i zQ9F7TY&D+3QQ2j&<@zF}ydziUJN^B)^+t3yzs)YoWsg%#Bi30tnItmK@SW9i{rT*s zYt|0p9P+!Ww>9ip?04;waKY<5q3jjn6H9+TzrS9P`2t_w&8vIamamvJL!fX|Yp2zP z{!X*Y+KZp4GG4fSaK|If%Z?NI#Ew3$R{9X3zdtfG!5~**p`VA^-9#2S``fAorER}f z`^1<{TlK>IonK_1a!O0?F`YO~%kzcX=6sm^P-x+UZBw?ZC;6KQKa85>=zGtL^EPAS zyrow*9QzY(bd>YB=u?hV-OVy9O?7o&nCliMCoeqhX*|_vf#-9!6UqW?4QE)BB?Gvxp@%G-eQ45}J|8PtGeA_kOXV?7BPyN2`wxy%x z{E6~qZ`VmVu$+$G)%@?#M!O|Tg);5@PF-i`WMz4~diuAgSGYrdf10k}8O3O+-JyO~ z{Zz`q)N3)5WBYP`9sHXa7x*eF=y^fS+~qgC-&iswoo|ddKXI@Aa*KUsJBnpBRr9;P zU$;HVazkV~_c!4qihmX?ikiZs`EC9+5eD0|Yw=y7Yh$Lh{N2bnk>gPx&lhfo*6-4K zMoX^*Yn0?N{82hkyi)nF#l@YRC(KHCUa$+wA9pvo-rpC-$en!o#QA3pSFhPk`uOon z#^g^+Z7%V(*v~a+z9)Cg$wkU;+X97qTGP0ToOnV__RgN2y!5F_Y2nfB`3obxmMXTW zpWzJRD*S2OzJ|9e_y0d**_m^%xl7LU%=k2^%G36^!yA>3&$6GV{_vPA-7kCJ!H>-| zK}MS=M)5g=*#?7i)3i^gPYX1De5x?v_aTY>J*uZRUfTF&(w9Pqm*#aJ4~iHsv2>AB zw+>L@ImKz2HCOd+(eLlFYjO@xdGLO}%H#=a^beH1Ubxo(k6r8E`yW!cu37$mZJ3Z~ z^G!TMicL&UEg>;SmND<|G4TV3cErC>`28z?-(yqvcAtlJ35hj33%Fe#6}dB53Qd)$ z{L*1jSEBY*gwwt)U7shkNo0{*;f`AZT%o5v zewr4mxkB*p)`Yf$OM_yrM8w5i`O$hpe~Va=bcBR&|H}{GTN$TDDpuOWd6c=jGCs2v zu$9eVG?cq-^JLzqAE~-~U%!0$^3KgoW_dH8uc^-2df?*Dqs4WHHr<=HZ_7WM*VjMz z`+EQ1dq=unqWXzV#n!Wzw*5LM?bPQnr&pZIVrqoXnaa<{_RYHG=KE{*_1(!wm6+oc z&K&FH^Lf4g{f)5Zc^30`TrPW}G&$~6TJq{6M+2iPMC?-Kk21_;6FYYJWAhJxn}DvY z?Bh?nDq7ZTD|P)hopJM46D6Om%Z#s2*3$TXe}Dh9?6dZ1)8tG~EcM>GpR@St?oOsf z4GW@HUq1G&(9`DC4vlMf7g{`cFRwF|S$KV(&Z$oeKNM~3?A-BY*<*p)I~McLEkEy; zwo2`!jZnYH{pk}0_s{Zv)Y8|r=#J&n#?RavLlgw9`Y*ZrGjc!wYjc*Nv9(a1V>kE1 z1=$f7SwlANm~bp8A&Y_i!+R;lS9%5<-#6{hxtCX2_wL;ak2sa^J?hdsdaC6O{=Ip1 zzR4@bb=|W!@dx%FfBWZc8UO8_x8yfBOQq}n{^v1wQ}NH@J9!u7FQ1%NTQBL@;4Ru( znSOk#E&scZEin&sA2-W}ziXQEdeiRfdxWpe`uWc2bMF6h`?y#R?>O~z!a3i?`yUG3 zR}?r_!9J%9SrEBPz$h{hgPR?KV*j7n7Ae5Q==Wx?lVr@F=FJxtDS4{FgmKTVeB z8B4K~i|k3JqNQi|uXYZdQu1P#nd1w={oM~uuUM?_oI9^3XM+CsYq#=R(ys4qwF)iU zB`z>w&H}IX2WK+YoqE%*wWVVo=h~Y4Aty{2y$w=h*;k*c33;cU^z_%6zl&CVn{qMs zHs5lm&a##J>y~M}&Oeb@k<|11`ucBrC~i+e&Q^moBTE|5!~ebs_H7o z=O_M?%oOHrmahv}1ZT(%iy zRr+p~JXz;C`Ie@3OMpE#??2jR~bY_?o!JS=$mu5mZ(%|~9&jl}`q&V9J=yXP(I z3eMN78s_@CbJy-@%M@C4=2Y7ai8#LMZ+~WAu6|y>&c44%aCw(N?uM>~GO~3RKId}& zXP-P7w`BgMiT?UOGb}DIJE`>P#Uj_$onCwz?zMZ?F@5_z^YPOEQ~K7*UC!I{rd4~v zXzG1(WpY?rBYdY`c%~QXe z-FavAy&Wc2dl}oa7hN_!(Efhz{r`XBY9Cw?o?DmsdYxqg=d)Ffe;p0ts*;YZx7l!k z!QH5s{ql@?k~L);o9DkjTK;~%Vso$KG&8Y?@2x-i>pqlo_uDHr_uR+`*?D?*3d>&C z2T5lF?$4=cjn*#Uo7P;j&2)xgq%w29$CZ1D4>jXH=Rd4Exg~Sk1e=_+ZL=2C#B{~{ z*t6+O-Dkdl?#9rM^{L*Mjz7G*L82so+p3<$ z0aJ>XaV#@u6SM9MN}axWv507nfTsD4tv6GAciQbTzOQUlZPKW;^zr4o)h*X9oeNUY z|NLynoew$R8{Gw0UJISPJ$8ZN2|2B;EsGW0cGuq9oWHSm{_W$zLen3-5ZX4O>&-U) zGb$BU$()u`Ld5Uhf3!4BEupadlvezo+%$#Ly)$dv?cBJ!{RLn zZKWkW6JAaVb`HDXd+5Y7;pEF_lFnM>Wpo6qKD^AU^^_y@y;k{!gZ}Z_&IjJ#`C)ld zdCo+p|Ig(`_)~@4ub7(U@tyX%FA)`fTdxPuVpsPxSjQ_!mrBT@BPY~TaOnjmn1))m%E_g(DLuktZs08D!ZVje?O=5OXDu) zGvB?vHJF*td~ry5G}(JY@)9*C>1!n?kKgyYb-6wJMNM;|LH5_150thrXPYIgSG?uj zF14|$f2uOGLqc2dy2SfD)7?DM7kPP0=(&Vl|1(?3Nl0<_)TvY6O|y32@kD;@{B&DS zx%rznN>>|3M#zP?xBW`)UVEhb8F!PL-tLTxZ#sOI2+N1(=q{AlJJq{Eb%TN4!I;@O zFKn5c*x%{ik6aKvYaK`4qgBektgN=VKACc^qkmp{zUORVh3*wE{y1d1uwA>!Gt-f` z@mPP4R`-oXzx`wn#LDkVoVh{fUXauxVGhH{g$vnOX3y!CaNJ?Jl8otzg0Qyt ze}wFLr?>g37Ja&?bim^0v+MIWF1HuqJvRH%dEO&$r1q4u?%930{auBT?b*%8ySbcF zi+rX!^sV{IWA>mj^!04n*S%(u7n#HT`dZY&{k&hyNxL=g&Z7^tXMJb4w#|_G!m`aa z?f?9SOB;_D{^<5vnUfKaCKvL2^ZKGil8;V4xipEBb*}&Oi{De#g_n8O2-U0X>zKlH zIrPKH#bPt(Fi*VmxIN{xjQyU6uQU1hJ}i~t+;mPmLjKfJ=@VA6d3)8044vmqTJ(*v ziO=)hTitap>e_=FzR#I+GIoF7T@F><+8IIjj>XOQ{Cd&PN2}s~)}*8V|9{XRQ69Z?4 z*;8c?h$ryY-7E=t#pI!&c}3;PZ`BhbY|Jyi{o5<$xm4|UGylJp3yM~sE}Q=M|0@^6 z(6^6{c-HZL*EMfDc3&($%1Y4t@nzO8&6b`mcU2fY4dz66oNsvf^}6)gGr^pfnIfOd z=zo)oKOPfd6n}i;0;z&~zYO%0yO;U%NM3NWFj)K~({ta0(+XE2qi+^Xp2ss|m#?m^ z@m_0}MWt)!De34sh|11wf56$j(W*8<6x8OVvn`&d__LnIC!RLuM=K}aet&yqr;lR-pT3^_ zNzrLPO-tJ~&K`SnC+5HUnMDt$i{GnfdZOn#_xrL~3)5zv{{OGtt&|?^J@huq>Ql;t z8~KqMhyL8*441evUAv*JL~QEI-EPMlCRc_|Z3!rUml2$iy-bqpAQOT=1yl4W({LiRlQj+=KP^%{u7%O98>;(>`qR}JL);f zf5*-FqLznqceOrxl=eR$WXsy{Hvftx_vHP*gtwL5{>T1|@zCbi_wLV6^t&EEXVrXz z-%Gyk?{_#mwNOy6c)?5EoqKtt>$9Sy>yO%))@{fR2rj+w<#={+wNv|5?sYnO-z<-{ z>zAFcXy&@TKk{; z#C!Av^F}FtHPf@)(h>?s%PsHD{{6q5|Igd|&-HfiShzR$f79)_*)sp@JLk$7Fr@!s zkSj9I3uxG6@GofjLZ-9!eDVHHeoR&cy#B>}ANT#Y&O5sQ)t+5TP!q8Y@R(%kJZw;I8$XMSCpMVkj|a#R=$#t;z|#~?HA15;rRMy zo1)S=yDh4I9h#z@+)YQ1NNjiQ{^OjpJlgze<^1>G&lkETJ~+59Q2d;_f~Jn%gH6rM zbI#rW)ovN|%~|V9Y^ZgS-RZ?fDNSS$8inG45Cv|H(zG=_Xx zc4w*gP0oXc@7_sThUuT+id@?j#9~ z+AkBicK>?o)p;)MLEKm0bwT&yINOgu_3R5t>Hc1@Q|4V=f15*xHVID%q^TS)!2I$({wj4=4fwI)sn==X{sd~uO|5|2|l^U#A#-}=v%&ofW@2< z623ecSO1;3zTIN>rOTGH^Y-78k7Si?ahu-G;i@4!wdoSqlvKrw0SErSU|G9#j;i;G zNfUR(q>1GmR*tNj!WkiJa`UODT%P%XLz~T21D2#{KHYv;d!b>St%Q%DdY8!af2r@| ztpJD0+iDwO^`WunuSum3WubT?){)+VbX4&W4g)Jt7as7n-<^6_?gI9op*2M?#Cgm?~@G59(Qi$apT`` z@vhO`qP5CKmL+V>&PV!{MpS!p}@` zC%U%1<^0J%_kH{V;~-X%#Er+egKr7Vo+fox{;Nl^_t#yQUtDeRv(;VmQkbJK+ zUE|1JQ9jdm(-Thy^d@xMFLzlfdvr>bX!Z)HE{Aj%@dclq|1l@FU2mRy;8nr%2h0gO zg(_A=vB|gXYHyBxn9iN>Q*`&V_|DYnTX$S>^=o>y=&$Gwu9}bkmImwW{$=5(6wOnp zd*0}Emu=&7R^KDOYh!O-+PY16P0ri7Jx6mnA3kD@NB^=&-a^WLkjpiRB7-v-y8x9ci@vfGI6 ztNU1V;=2BJ{eP0aN6tQeWgzm8^Ke2{slXeKw$RgyAC}+wr*Zy)Y{di5=RZH4o-L-c zs;JQLwV#eb8RO~`|9&tseD3zmTXy)^`eo}IK0Wa;eD(M@|HcfT@Pq}E4GcJ6t8g7D zOT4gg!gu+JM?>bfB)>PUdvoGUg12`_-I7_or#5fkDOxf~cw@mm_np}ur{`N0CUvVA zAD%qTW93$>e_uKa&WT03JrmRPNS|G{a!Y98^P@$9S2Rl_Z+dRE@KQNnbhTzjvC__n zGNa^!Co49FD22wcXsu^cHJZ4`LvPZ<&s#rD){|43aIyZ$};RX)SEf9hIhXU|*eHpom9dhkYMg5N@xlpPk!6Aquqj7S$TTRiX0 z*}|rpPoH)@V{xpge%N?9RDXTH!10U+*RQARMa?evtyZqjy~woRaPB7c{r9W0*8kpi zh=khn+tzP89HvP)lRUD$n zw?F>(r~AsXBUY#8w;NpA!Sgxd=j;m&n`JXLFqqVYU&H2_bPvUy9i1*F<98>pNoyF@uCe4hI zpFEp!`;8pC6D)g7@@`#yW7l%SO5k_A^d2MU+SlK5?_bIdOwo*V+L5*|DRBK4Wq(OY z<5ickel1OOnq=_hyQ{Cmqf1^6Yh0{n?Q*}C*u4Mi%9AyPqVI3G-imvj#NC>lUD#%! zBT|`bWBk6l@73A`_v6z7lztT0)f~@T5-O8)^US*pq12P-%`aBxmaVH#xbS;dyN`e5 zQ6|H3v*yijE@^9))xBY~NPRs^0lCdYrYng1rTcGAj$FG0r|~@|5dXT%DzZ zzWy!us0aaF|0lmB>VD5zTX{l#^~T6GK6%Iew|zY5U%u|(o9cOTA5tZBRfL#CS-+|l z1iJKoZ~y-A=f!qK@5$Reom7|X6o|bTr^IfrA2snviU0a7)z!cD$mhEj^z}Ea{nWST z`N3PV3W`ycAK4Cbtra!QbJu_UaAW5`rmZFyn(bSrL~178Wz6A=keIuQzw2M!>6u0; z#moNx`@LX86mMMK?b27R%=a7rf9}ql?q7D+q3Ts-yYMmR8FT76OLt~2dyrh;spGS{ zxBS@W?h~!N2Tk|zx%aT^d)CT5-4 z$JIqPiQ3r4J=(1=^!x%-mf?o4mG!SxBA>rrDt*jo&PsXR<1Ur!=dBW1`ev(*i|759 zI^oB{r@ua2AfvCvU!%?=?UjCC>B-N64A7SJejtd&TEG1>PyM8ji+Z?)_#|uNnE&E!R>i zC3fRvOZNHy1oPK_eJYf^_gqZ7d3x>BzfT!UrcXL@^1jxYOXp{Ao5eOuNPOwCbu3qw zf7rF{YVOxu0nyag{`RJAZtf!1o9CLvttd$epC`b@%Czs5WADv|#BT@aSUV=c>%#$9~1{{_^Pl=ELz? zt@(Yy(fE8XtI6xexy*;M#oE_?Tv*H_F=chVnfit!r+-iHiTvFY9s2Z2WNc+w?!3!iOe+?8HL+oSDN>2xv^)ngR!W?<}Hb@BE6SQ z+?t{j;l0d#bMCp0$Y{5$&2u*vOx>Cqv{kilhSS-No1cY>cC|k}ILqs_R3rBV3nop* z2W+d-63<3Yetfq1?NJ6PKE`(fDp8+v&P`o-L9xPF~Bm zr_lBR%>XYxh=NVg`6z$!+Rl0A7X2aftZOD(-k{eZ(h^l zjxRQc9j|-jaZK`j5O78zeqxG{g@|o-ch1Kr=i?XVicDI1_|i_9YbKv&X`fW`c+SS% zaf>behH1cOMbp&IhHonCJw2a$oDXz~aM{end#J|6H}WLY@|shY3{ErmtN&)OjgL!D zuna0+r)Vd?Y}O_prfuqW*7xpnoZqbSdj?-UH?h;`;w=`YXw|LM)Zn zP}6i;(c!eO4^Hnow7}2!Tx0fu7cw5^i_hiF^_+P%*Isr}5>xWET|85aPRL$ddPVN< zR!ygx%F}}NE)pCElOA~b-pIWqR(tBl$(Kv*nvPX=_`N?n;Zcg&g!dDAbBe@I9Qm?s z{#*Un3xS@k!ilEW6#AB3(YIT8?!Atw8n5+&LwUj9te*X^_b^aA9nG!ask_|f*andY z0lV4a7#HnuENpBpPCLxwqWVDe^!8_WbPlW9hHZGBFqL7qJg3|CNNG#XF!9tK3tl;G zt!-YM7xeH=q4$CRuU?2gFh01nwkUQ3`x!1V@mW=&_AGwfj>Xa|pNM`ix^TEIpTWgp zZa7o%oo}ZST+YR@e(8M2+mv`zzmH+>t7HcT9>;S@*58&LJ*dV}_{#sUW{s-XRjujj z?(4ibR9qHn2nbowtpJ*npe(~U?|Aj?5nNug-i9MlkJ0)=`7t6^wZK?Rl4&kLAc^%${SY8|cR3cZsbL@b*$eF@)c39qGYWP+;(MaSW-r_4Y3N43*G@_Blpg=OR@-0yw?=+PYUf>6s+La!|&jXm5Dnq^1=t zj*bpT9gHSS*63hybSRpnLIVQ>1BU_wLjwa7ls7=aUMZDi*|)Ty%Z9VsW3=?zh{bZzeSJp2}LiHf&mC+RRNa7Ija%nLdB7 zkNMq_tJD6UKmTd>o5{81_iMiweYxl!e*WWwW`3;zkWv56uU@||DsRunV?{R-+o$@P z-CV*Y66|B>8o%B0*^I?i?+)|Z@3~WQ+4pKTlR^W7!OV)6OQ)Z@nLa;u+ugF;M(1rl zf3Yp#*?QLc{hrj_Z?|bbpHu8t`Sa=YxdIG~EDfH4QS#qzWv@57U-P;5`-O{ZOH-!D zmT69pDe4qvQD|TYP;1l+TRyKUD>8HG)G9vK&F8GLx7J5#e!E})zvz_a@+sQub}V|e zs`UHa^6BdHDi-beaESZrwEz2GuiJf!*ZfYvzdz6I!+$?B%#1x*e8%Xsj_v+;r?l6{ z_}Tw0iQNM7UR-`$<#}0d({Og z0l%h2=iR(4F?s3qxLq>!e?EqW8-ff_D@vbNnHE{|@#yZbPwBIBw-v_DKla?BXV0~x z;_)$M_bQ*ilGr8tc#^kXWUnxz0|R4%QptJS?^AlsZmrnpWPddzc3`nYZufv(9g}DK#g}Zs!Cqn^*O!?|tGi$@De5I`;l})a_@{z`*1%vtg~A(Jx(UfQnR^=j4Ad)4ntA2G5BFdW-{+0T0ItYh1PU2D&p zUSE^8tZMJBce`GnlFD9l(ZV}?*F;88)Ex7lzFNQLA$RI2&E+y7mgf&O+5bH0uhzLT z=d)a&^}8KL*JFx#tL9g}kNVk{7m$2|0lx)$Q>`cX?fJ z-K|#^oci|r{{MfUo=o=l+xGO+m&^XsORq(y2khMQb#1;bzwMU^-0Yx8-7&B7+04FV zyG;r6Z_fXhZ?<9W_Ip(UKbMpqIZy&h=!s$LuE$lsb)5P-cl+IKdp@1g=6YeO+3lyO zvN@SE6BM_$zy3V8zrXSJ)z9bc*B9JU$tZc%xAk`3ZcQ1G{)KZYpUrF*lZ>f+I<-_= z>+tJ!(~NAsUI`ZE14ZuS-S7A9K4W$}hgs`Vx8AM{Q;E4oPu@gu>+ku{uxWO3pXHez zR#22l9jjfkbn#Xu+rM8fPhY)mm)FB7_pWnh#;+;38X9gY#{vqUyNv8I1{pGkAGXV{ zOK5w!`~Kf|3yN%BENB+t0a^8S-|u(Y#^)>^H+X$+lg^8XtNj|-D-|gW96QyyhXPx1x@z&#&3k z1WF#KQYzc)UM`(37hriWb$YCb&-}MrF8eW`s<@dt{pul5Y2#B@_xtvJz7-PwwqLJ2 z&9DDGyXoa->-T$#1GG;_rq5ZJz`!EVaPqX5_F9t#L3{3&-PW99xaq9fZ61x+p<$7( z3qih~+`s?d*Y(U=M}qw$T|o1C|z#I+R7Y z8W@-iGH+Ep?qv_DaEgC2#%}U+$G$_T@@tI{D@J+UH3(Q>TY&fKu_2r@Hx-ryIQ9 z@BMynb%5H0-S78RAJ`d@GSL<6>HxKut{n39e+pZT8f|{PSS+T@0?Hb>mwnCeHh8_~ z*59)rKyAYF`So=NcFw5#^->fRyb28p4WQ&E61$;GF9O6ms!-D9_ySatEPJTC>>(%- zN#uvc*Oqb%`l;yc{Sw42$i$(*FnJrV*^LB;rQfFn`vqzUpYT+lYw{o_XUj!5aI%=O z<;}L+dCXdSf4|*6-)W}9x0~tn8J{PexBVUh^~;LIeNq80@8<1(D{_hjocb-qkIPlB zaRMb7HC^%8l8X&q@4d9wE(uVZAQ~2tcwpy_CzHIvUNy+9e9*|w7E+ZyGj-ahQ`+k_ zW_;91wflU=nBPL2-~P`AaLPKRvR~q1>Gjy<0mn9jGt;?)cZ$!ig(e%d**QiFKKZKCt{er21}tlV!C1j(Jtr1SR_ zIy^1E7MZTAzyHssCQ1Les+UW%-uS+@-0^nXZEzuHkXiNhYWV3rpU+t@V5<3eR9t(; z$0vul^<@ISaqDbofcP{w^8ezkLLfETQ$D5~mo1kG_-6fPLo-zN`JCc&3-Th)+x=eC zB$=%FcuHyZ&G>?YtXymk42&9+|5&vAd^S7Z!S%FF5o4PO(2E5zDRH`|TFv)Q6hO=WM!DaF~}Xt>;e3W#80Q zE0;+HJi8uWzZV>eYVz9a_k3#L`e=4LXK}!>6JgQ0rVG}D$CiddjEVEJe!GQn>Z6s* z=k5CQHordDptkI|Y*XjCkKn^47Cl(6r~*tJS-m z@3XJp^T{hRb!sTvl()CUltVE{(iGr z6jbXRST;M)YQZ9#?{|u)AD654IoNYyalc*EvpL1*7*ltFB3}YjN&fhK|9@QCtjuMM zqR)8EZa9RmG1_$SuXO&Nj}4pVB(_PO0%gDgPz9gGE?4njfu3`_T-Ayu$z}QVzi&66 z0yTQWK=ls8+PIpJM>pNdTFtw{!r%OE$y5FMpWTOEUVJ(|K5p6EvRjO)JB~?ZPnjN9 z<@pfg{w?0-3oW0`NOoYYn&>K~*>Cfy@@nC<=6XoVpnV>f0 zDR47e&i3;eOGR%jK~SpaSIZ z)T12tzir!I`lj#e{QrN_H|b9AX@7a;ZPv%5;_KNKgQ~9pH#_5NPTaaCD>m)D`Qi9HZyJ3r?2blYbVLg`S)t|`moi9(i7cu9YM}|?8z2h zF7e!{J!tFTs0{APO^fUvIcf@3Xe&q_E|oAal>fB)XT`>f;4-#`C$ zJ73@W%?9V0Rj*cB?^|wqEn@MXzwhhI!R_f&EN>>2f_f8@>2o$FFSVWeGk4p~Wp^qb z_X@wQ-tn+adX+2d@>yA{`raolo0+D?E?=`jFC64y;cg%2-7lBTE?aZp`eU_gvE_G* zE_td?O`l&|)+C?b_pn9SPviBP&3?x}>U`aHR4jVR@Av!fbFv9IFwBscTYSz^_Bv1I z-L$!-*FX*Zsi5k8&o-?mTmOA%x1Z8q|L1Y)w8*rVRXvfnAMg2muGk6G_CNJHfo&yE z(D{Tfc~k%7yxg_*+pX;IyF1qH`}JzydiA2~vE@@2_uK8t+4*!@>b9F{(w{0o{mG&~ zACFI;9$$B}6I69+tl!M~$It%nmrbX%R`W!DT)%y0`n;W7dXnaw-tYT8ZL+`J&ZykK zzh19jf5=75rRHI)_$uB1^XES;zo~wAH>kqd^z~ZwbkVSgg;KjOzJ8r%@ONhVJWEd> zMNm;-a1#_Ft3)r0$5lL>=X@|CpIc{x!_PzF`vTVOd?uyN0cwp0O|2`-S>+SpT=+k2PzboEsJT8~IxYulz7(-0n&Znmqb?Zf~-Dh{VG5>JDc2R`E153%TFvg-={rNO~zs~(X&&*H9 zRlg0*{rP`!pVg{4<@YL|-v9q^dlQobLnG(YXS4J5f_)5+ZrGl=Z052vme1#;ZagZc zt-WT0)6I&09;jbh4fri%K_kE!{1H1+q}?fRR~+x?z! z{ar|~Z|Ky&+xLB4`{~8vek~gbXK($zCCAS{bmG=sQXt^q!0__^whxE6r-S~)rpvcK}?S2*7+y&iko_`J1dwEj86q}=6m%Rq_( ztsk}sSM9pDY);XsPwUF}uFl!@a#<>9IAV70wn)=RC$R$gff0_QpH@SJ3s> z+3$8f*9(uUO!Yrs`mj}enq=CHg&%i@MW%|f_CdTMT+E=kers3Fhi3UVziQ0y6ddm8 zGd^E?T6g;t`+pz%ujUwn1}T1ip8wzGrr-Qo2fk0=|7Yo(+HW^QE9cbz`+1eAMss=H z?$_&fubNp6>HvWrMHdu_vbCy?kR=rr*e(d$viHu*D&#$X0y;pQDs4e+rt9YCSXe49D z_qnd}pJ%=;FW=TKU$;Z+d=2}PQc!VZvaMpO-PS!%Z>_S=u8hAPSDpK{PJJ80q--6< zKRw3hH1_|xy8hUfoT4>9?ao)8D4llw?UobELE+Yu$C$Zr_s72EEJMC}RLyzwZ|Nor-U-Ny)avue35g{{OG*$GwkQy*^d4+i(BxyYKs6R2u6od1<--``-7w+6t*r59VCX zySsZ@RMyHYfxiXKKN{KPZdBae#jBs_fs-=lb)198q8O!90rJ?nlDU zSK<4k_QqXK*S%l-zW)F3)1cOUO69*7?)IzpE#9QN{Z3Ku`-h+bjU9hM&A)l4pGn{U z;b!`5-T#;cjPl{_4@g&=Df}4GtF`7zg{k1e=J&6flEQ5;Xnnq0;sE0GFNc5 z_{`21&ho!5Y+&1U-t>A*=+WJ?H=naw{qf_<9p4{qI<1#o(+8>ozf=qS`h4C#{@?fd z|J$=SyxyX!kg{;!!-_|p>Zg|5eO+nv^-A#fKUOPC-t2zAFL_s+;3%we^0I1e9mf3@nnMu3AOiYzke;6!&&8fK6E<^XynUw|G!(=>tk7a z7CMDqe!Tr|+3l?PTR2p{eET#@bMY3XDT#hNYfdP(o7`*2xV7z0(P_o|@tGN!zLDmw zE0^VVs!o##c$s(o^b_{Cw_mMZZf0`o3>lUoATyuzq4K zpWTlHMiv$UiF(E-uU4;rXC+a)Vx7WEt4Y&ZCZ5|dm8&E;)uP@-IAoUAC4al0PbR$2 zf4}$pyDIk0^BJGm|NrS9nZ0(aTZ~wx|4Nl_RoBaBrcRUmn7#M+-S={n^Xy(;ySO$~ zQI}tN>hABLn)2ct=H}4%v9FBZNB%V@740a^+usT3i7MAgzu{V*d5<9ong}FZC|fN z_pi!XxAWPod8gH$Cr$P>ll=Bt^4v7L!@fnw>vojOTj##ayP(3j z^kv~oefudTKVN;h5ppc3^L;pjr|RODTic|ajLMeH1{MFI-iI>cwPS1M)qcCVFK)4T zOo8L|>wBKht3LMox!TmL%et->Y~PZzR5W=BV~WYtXL%3TMl*PtuYY*2a{c!0d!Fjr z_Rc>s_t(W~xx2c*8ckCdeZ%tN;=VX1k7EaVN*O#Y+ZeWzW;<$H_y-G>oc{k@|?)6I@4$U z&f-bz`(?ZLz2uMkwS&*&SoN1|*=ct!`v2_P(lqf@+Oc^{qOS)8-%xw-HF(!dlfBnk zRvpRNez&YPRz3Y;tN1#n2Odl(GPd=c&)B86i*>@qyyNoqHP=7=J?UR}>FdY*io?>Y z7-op=UYHk=vVS3y|FP*`UhiF6y6|r`CA<`e6T74Nzw9~+5XZ(wL*U}EyP z&+M`N>MG-ak{wgm@UZz_UlUe-uQL65-TRd>rLTgf?z_BFrKIXi&F8b%OFxwDGB(|N z&+xd+ejyA|7q%aldx5vK0hp}l{}TV`|Y)g8tJ?p4=etkNhv7Ue*TQ~{U67&HnncaN_o^E zoxf+Jl*@d98*dBOGVgX5atM0&dfo0k%NGlpPi;Id7rpnHSagnIiuuYjQ%q(tO!_{x zxa3Npd+7S#Pxb3}>g;&X6e~ZORmpq$wCT%sKjYkg|JSwctHf4ZGIG;uR9h?SyZ-y! z_chBue*I*7NjD^2sZF`hVo}!Iv+26A(~Z=$b-VX&(|LN#b?;LdQ(Haeudn}F)t09o z_^Gq`%%upknQI=rTs~hez-;E*wb3_AX6Gp0mH%ve>ut<3=_IzZ={2!zx6^ATf+_}| z2@FLC7}-ti($hROW!a+AW!slI9+Tt_l-gBYy1V-Rm*b}07fo%K-A=gFvfQtKX5Oxs zQtW&CR2pi&ZJz)3*59Qs^D39!&zZG#`OB}@BGcy{>nhPY?<>?WtItsMe%;R>$L;U^ zu-g4@*XxS^XVz@HmDPKEa##7G?biydaaYV;Bv+4kfzA^;^?Nrz)b7+>E#b)$fY!@Htvj z*;%q4+{XiwRd(^RoS&ufmq$T!cFEP( zUv~6}N_%b3RxsDIx_ifSg4D|={C9WxfvO^fh8|CblXFky-CcPrck9)#-g2+%$n#3U z%gpx11-IH}8-1y&dAc<((0BbrhO6rtCg~aauAh9ZVPT3w?d8Xt{ajua*7nA+t$4=w z!sAqm#l6q-zJEzw+gJBOjK%K%&-35E$VAR4*zxz@?!9@}vL(HuzV7lpZFPrX(#rDa z-HuZe{ccRqz1w+f>7{$QZc7)YT-^Jx=lv3O{b!-#XJiPd2 zM@gfU*REN-%cFCm>}P+ge;s|_>dB3XJa68MC9nv9nviN0+#YGiBz?o@ePNW(bG7I$ zH@!Re?UszF;PRF&UL~{lmG59o`TzHOevSU#HH#+{Ps@vXX?JPgQu~UR5Bc(T34K*6 zzt>)!Ub|!0SIza;r6l5a`R=}5a(s8mj>&gR_C8-%y*%>BF4hTa{Nk%3&Q|ZZ`*auU zgk_xKReLv1SX+DW+zE?S%l_u?e!Fd6+~$>2HwABB5o5nt^Loi^FYSco=c9tlH%?@m z(9*N)_}{nr^{-dv1ij3UGG4*6xI1;%y|0U+%dHq$7+F{*a9XjcXlI(go&C;S@`vq} zYa(i!QWKB(eR8k1s6Bfyn3T6Y^qL{Mvi&SW>s{wFt$ROYitwJZw2;s6JOL zRi=}_{>S0N^8$0%n4Hy`;$SE5(LRpr?ig&e@0m}-Wfm!|K#Qe0fm*57xvWd7GxCww>Mxdwm8+<*Dz7NN@O?aMB* zO<1PqF6nYB|8Q(xzil5?1q7ItcyrcQKh#lzO*U+t!M(@q(%doAm)etq}S{r~^I%YHwd@NuEo zJ?T7#%&1)6_#a#^!J#V~+6 zZcE-XF1c;R5P4(O7mJKt^3lhfO-@?z`fhee-B2KzbnBjuhxwF;Y!jA!WSsjev)^s| zy8OSltb8w8#ejMp((~W&(Vm+fu@Wis}b7w*>XQ$?@VfJ`w zw80{~@XEC4yq$f&Q$DV(W1q0hD`MG$H_O+o{Zz%F;9Pof_rG7S-T&MHHHuAnvzP0N zZir)!UM@Lp*UN1SQkkwlk#d+7rE*NGA?4WaWu?w)+ZZmDtVtGiFq*J0bK|wTJ3Y`; zlFjgP+llz)*4?1)*Y#34quzHJ+ZM7-Han{q%&+68y7jWdYtS@!A>;asEE7zx$86rX zJN((R_tR~{FQ0lDnOL*ssoj+-)xBQve|A2bwfdvf$zN>BS9Skv^Y5IU={}q7PP)#) zf|6+Eg_mdXO!j8@`GDR2M&bQA|LbD<-!@+`o*jOrcj^i$uXZp_6kNPTrFCZ9Y^j`@=@&?m6f6!MFR?Gh8xRTFB&RA0>6# zAT4Y6pHHW&tNK>2-*-zVLZRWn4Ymn)6yKTs*pvC|Sje5A#@Acwmo>@A7aiZ9x$k$# zwyKw#F05zRbieL5Z+1h`OM`8TGg)6b^I0zWVzl{O!SBlcttE3`mc_o^c02EI-MOCs znX|&oZ=bLGw%KS-=*`UyDf*vg&PtsYDWz`z;>s$soh%dbUKf3_u9>cQZKcJl6^q?U zKR-Ia%>O2v0n~7jlAq2g^lR_bzeP49@)E6cWCJqjT#e52pFV;jq)rsB}ZW3Al z_p5uT;5nxTP~-LZ+k4gTWj}q4OkS{3+V9lG4U&kH?m13KlzFso=`1gJN{*NCk z-(5OfrTa2#io)e?oAl*H``_(){i|%Q+LC>L-{$An#Lx6tkoolGB?H?RyI!xm{i7x_ z#wa=ER_fEQ%Z@*hdgpq%Y1-XgMZS8BD&^N^e>)!~zVx`E^W1gEL0y0g`(}z}?^u0l z`>85N-+uq^`|8*xl7 z31vQol{L$mCfavZzug*s-R@T@>&Gtbbs08&pkbTmUd8J1e;$cT%g;Ada|Vt3?k*M8jUZ~b=5rM04~RxY}idGuRetjZ>(-vLI8BQHO`9UbkaEqWpE zxk~vW<84VR)&=a$whf8Mn{`*`F6(946)y|-|2V3@YhT%}uO-&o?f*RFSAWeg>Dv5d zd3o7uw_cmDe)?=)-|X8AB?qrIXW;8=GOoF z*?WKOT{~^-HyaKwm@T#Q+OE0f;BKcrqsrw|-!4tf?zjDxQPbzsJ=6M?<-I!x&)wZ) zeBNer;TyNRy&AeEdr!S_-n(ahh*G<;^i>Zgk7ZitVy`Sq_IPlst{P(?|Z-R zciG%!#<~H%x{i0RC7#T2n3<|I^|`xT<&sq$dh`np~d^#06|L>MtOOj@Evd%j8v?Tg}auJWh%&qHgTb@4fv8CzF z^mX$sEn#D>fr1%XFo^H774<1ZGuUUFF)TVNokze?O->J?7GnD*1mO z+INS&`22U8UenL~mj-z&Z*NRnP_--MYHIOW)9WGkc{#WH_Md$9*;lBen0dwR(%I5V zmg+MTRQ}H`y%t&hYYr#4KiG5g8)#f-xuL|67PWHs?Hq^0cQ1KOT$hmi)W7~$@O7^n8QbQT-CDW4;AZZJPafMsChRVi zojr%i-+ziKD+)_{{N5T^A0^K*yi)~_Uj`|mgc^j7xR`!r_D@V zwfpK_pFGATJe$2!E>FAp)#%*H>7Q?!oPArct%NcA{iG}JuJ8Z%bp?}0U+L?$+uwQL z{PHXI+t&59xi0G`UOTmHnkJ;>wl6Qz@z;#xKFNi2uUcZk0^ zeWNSy{C?1qn%|c0QVyrGH=CBu?yp~=yE*Nvm6-DHwcBoGDc7xSSsGGue0oW6_514k z)~{Zh#(G%!?!4{2bnESk*0^nF=9q@GmKO0U{QURhOuC)%qfNdG{AW7xX7l;DwK?;4*R8Bsw!ZL~+JUMZSA+MF0Q<^`9Tx6 z**>>-EPdx0E>&GGIjK7R%}$+5Ruc?44#zIgsonQ^$HOg7s#8*BPPR-Cm@sq4r7nXz zugz1ZlyVt4l$#ixdtv*&^8AK4>%Xsz=PkH?{?ro*2d~o-OJkA`|BO5J%XX$|mWt8o znC+VLt@p+)+bnm^sdMMaOSxyf)?a4$|IB><%Df*u&m%V5c`o0YRyFbZYqPC++1GEE zOyvRP=!&~1ecU83%qh5N_rroY^#0#><>Ads@^`&l=DANN=-^3*pDdeOCVndv;Ix`q ztN!UeRs)z(#;|D4c%lKm*Ay%zf!zEE8FizhtfN-)W7XI6E>$GuNx6CKRlj z&%<|q?wt^$_RS~K^o`DzMBTsibnV}$vpHTfdlWC5&#(PX5UYwB(F?-rQm8Gp{Hz zS|qCLZhy=8b#?sTtoT!EYjrt$X0BNzpE_BR`}5ri=OeStPfQ7n3NQ|i-2So!G!HOc zbmE_fb)dB>Za?>b^$wCdocmHkIV1DL>z*x}OckFfV7_j~)88CV2f%>V!AxjksQ zN(;1F^YOMDN!?RHOTH3)nLxv)ujc4&JR&6gcBAg@H=AtVf>un1{XTDe-sb7m@c6eo z&QElgvwUZDFS*}VYJT?DtKsXv-7GlF>t6Gx-~M04SDDRcjJ*ASFAtjk7c_I(`7TDL z>cv9lea!Q!UTMn9F)*_HdfY7kCn5CUjM8h7tF7lwk1dPb`FvjWDbetlz&_h=H#U8_ zbmvg9^+NdZ{d`xx|MqtV(Bx#P&gV|chBNm6|6JX;wDsAm z)$8@v@A=fle?EKL&9vF8a{TOmKH>cItNzbp`PJ9!|Np-KJ_R%jWMBW|u>30fl*|71 zwW1bQpv6?DSFK(b_4fNo|GFRsK?k*244>ZD|KIIneAeV@a^0WD^8CO4dh72EX>APo z@VcS!|KIQYx88p`tuHSg^zYa8{onSUnay$YF=(w@p+ z`@;F^|K*|UW20<8?FKD0@Ok%T|2-y;W1F5-2;UNL0EPFf?)X1R{(tYuSG`!c>FKoS zX=}IN+w~YU3vhqB?zS6?K&v2A_x*gPt-WqXl4-@)lj`$jw)?+Z@@~^V8 zzP{ge+Iv^!ll$)SwI#=|@A!5rd-eV8d_wykG;yz*|M#VT{ihgCCEGU`22Sc6R(3RlW19It#$H$ zU%0PcSGd;C=Hro9KaF-+HLnE?YK5OSe!u7Qw9v4~rB-d?@iiNtGhF%?_vv0SkEHv= zYc01x9p+c1!5hyPuibEn>+1g7+9@~nuSe{%11$@g(<)ufOm6zW3GtV%hDtL36|Rm+lVR&8@#@L)Dqv_y2t}0@+phD>@TUIr$J=h_tv*+K zEb-}_KiBL3hJz}uBR_lrQ&1K<_L>&|{N+WqmE^y=i* zr;Q($Y`q$`T5fvwH{tm|jx^*g2laWk{*S)@>)NUs(2D57`}S+s?f>`d)5re$Jvuv| zOuB0L-1;SGj`HVo`~NpT{`w1=6#M%hwD5ZG_L}}@J3jweKELi)!{6fEr@yV%&p)3Y zSO5R-_wUDfSOhBmdZ;SZpIA0KZ`Kb)dee?BEnDHj`n+HeH}C#zS{m<=~B)2%j^DyY_b0P@VyG7;_+a6v-eBiJ(|3B z>!XP9BT?)h_J8cn*8we6-BGW<{Z7%-hwbuuZad6KH>PYU#tG#m5T`a znJdpcN#H`9pJo5nDW1P2Cj2r4O^$z!vE*O5W&5=BD1GnS7SlkBk?*`Omc4fOT+qyW zdAr|A?OwGtYcHD>uhaRj;q2#sl@{H-{{I|r+b^q?+HZgUJZ|@GBWM*y-);T(g&O7e zYs*U)FRD8OS~B|Xe{t{j^L5h%?nT|xT)&HFQ|*t#@^S)F=PjSlDZHNhxAM%^3%_Qb zw)E!?1FcGU6JfU2-1pP}we$C1OFsAixtsTD^Zh^1Uj6BN;cR{J8O!Ty=kLw0eI5Py zTmIj=wcGE2D(Z^69<6KtG0*G@(=yn}9ns#lP^S2d;bW~=l`*lNo=cU>yFTx}Be~Z0 z;}PM|rHc0P^?$#vuD?F@@kN#ie>VELDj%QR74h!SvZ9IBo4?LI755}B>6`zIlhrGn z-QL~0CVKwb?zvy@eh&$%`};b+fAx)Jug^c~*0)QtKU7^c1~&P^%FZ+@BWbb>)9V`(GNSm->Y8TRa`fH_lZ)5nkN(8kA=1b7v}P} zDRU}t_Le^I`M&R3w(fHs(Op$sH$%Q{iqF<|cz5A?>OAWUOP5|@%-%iUJpb>T9q;G*+>KeCFSq2+E5qE% zc{R_kh+JiVZoSs=$7cWf=q*LEOLbluc|<Zr@7LA!d|bRYqqP0simnOicy+37Im4uNK{wS?uSRj| z>t-KOS@7)dwy>(%#~NyWU7o+LZeCZ^%cAMFv5TYbR=r+(_59`9Jd1mO-<9`sJ8fMP zAIyIDTf@wo_qtPOl*{80w>~fR^_AXr^R6iMuZ~{)ieZv&pOVbSGDDm@|~COtCWA)LbmZtItu=k3j~cFM^{aAn0P$Z$4OV0 z{b%I0%`2AP4v)Q=%|GpJqGtJ2VVO%=!Ml9k&lYRj$fe-ht#mCoIem@elF&8p^1`K# zxn4=EG+|V^&v@5P`6Fl%&#mR}^Z*6b%D72&lB#)O4q+pnZLhA?)9hjyPKX_ zuQ4eRU%R__>Fu~|hLb;Eefb+${ysbRXfBt+&WDrEmohoujk>01?)hm)4`&v?!dl(g zYhNX=+ug_s3O^O?^7qCwOdm_X+Wq5Z*^R{Z!rwAA9}ZqUA7pqo;e%hatV58X{Aq?u zagG^ZL6e@h)R|B2Ub&lX>pj``YK|(umm6JDsofr}z1>+iBwW93`tF@?`79nZ9Amh2 z>*%Vx>2{xIX3Z~&&g4?~TJ5jxeR=1aXx$>2yG$Ox-d-<1dEWZ{p1$)_^(?+dOt{t+ zuhqHpUXjh=iR&CnTX*KIU9Fv0xqoTyt$QJDuh&_Zn$HJKv%bz=A~fG&s#p4{E$=3o zPh(V3+&{S_Fq>CF^6-VKKI@YXFD|l8uo5Ym{dJApfqzri*Q7cycwS%paK&zeG`4+l zDqG5=j$hU^d1k%Pv_ZwBbW+&SIA)K(F+1PQvUC3G+qWfWEBl0Hj$b89^cxx&m?qEV zS+p*lGfS3vVpqZTtCypJa7ZxZ6r&*{2&K4pHp-{9Bhs$8Ber)D^P+{M5Bu`RkXk=AF0wE_2Ov)`h_S zi6?*bospUP|GdP4OBZ6b%U={P-z8^oM=B(%a$e8Q6E}LVCHr}-I_}q-%CM68SODV^ zz6p}^=w9)+S-}k{MS4aOhx~3UhT~uznR!Gu0fL~yq1e3?T(*bMC zBVVU)VO)~O`uf_b0RJ-+3LVxms$71-=dKod;_pHeU<&-Nsj-;z1T<~+Tr5HuR_+ce zojzWge{##xl~>B89OhlyS9N!4 zkN1gs<9jPj?lk7dZ(q2j^yw@KCXd?OWk$C%4NB9@R$n)k^kq1i{QA7Z^4BW;tJM`& zEN7C-WLTN5vpTvz*j!w>uT-u7fWaGm+%>+Bu#8C5QKruzh+OI`l1W}+u+ z(cSIqUMoT}ZpfZjU2d0SpRf5DP`>(v$n>?`udj-}sZzav$;`mw@9WK5w{5t>xTIEX z?k=IJTq)HHRxWRRYsQ<;R<*rcb#?vxE=MuV*J|q{*Na}TbIqG)ZmjdP{OgaFKWpLy zca}bVeeIN6Y}(gHji9U^ZQ<3v)i&tiI`;H(E`^mgSA*5AU!JvXRrKyn8A*HP9J3iR z-~S5U?xwP3+q%lYaF*AmNm1!5wAPoH?-+!GY`c{`=r{Cwu*T+rvteroxq*$Bxu8{DXSEe~` zQVy>iW+rFMJ!Wl`w}S7Qcs`g?uhU=KQ)n`_>tSEh{~E z_|vT;a-}QQrazR*WmtK9+SXHFXPgh%wWjac^9BZ{WVNZ2ckTAtc4o?C$Ym974wGZO;-Upz1WGDPO{aeL8ERRIOR72Xz~w=KVuWBAiKDqPQqHM_Rm zi22xMrAc9Je+qrHookQp3}#q)-POaxgVm}&B%1x)^}CK$yeXCIcW$nF)Ttg;yY2PA zrz;({u+2R4Ddc?Hl*T)t9d)a}&+qbxbzB?WbIhpW%6t3gM#m>yo3rbD{qHUNe7Oa$ zm2dxiulUbP@A;qSo(ij*_WiP*ZNc7(q<2>7AIf7V`d?pt=1Xw6+vQkY(K&y%@0(X7 zzISi;HPh=c#&23VqEnY`{$*P}TV{crc!<8!bf-@V_ii~CV|s}pDC*ii&le{xme;7KsI9DO+q@-g+q$fH_D3~po8$cL z_W$%-b^W=AcV_m@ev^hPci$VOX`K((zccIqHC+cztCz2)PMFjX*WO>_79HL1CgkvG z*3U?%h--0~vg*O>gYNJ8ch;khk3%8gSQYPtl7*RjtMBYvy6HyM^>v}krk{Cr^KJi0 z`QLubcP=*tomaS0C$3+==C$?5wb8$(-Y+_>+smjT`teTfTJ40j$0ykTo4c#q~lyCX6QV_5Vk(y-ma&& zUR=?d$Pm@9d}`W8Bh;QFq6X!1Ma4MVo%hOx0 zOm_d9A$FXBfu|94Ev0ae4tnSIoWJCl z_LBP2H2+ib*S^ThuVR|d<-IHHsiEYFIb8v@;TtCZF+6+sb4&BLLZ+8-6YW1(wrx2S zQ+QOg_k1w>gk9VrPG0XOqy^-?yHXY56`dt zJns_IWxKs^B33YahzaSe_idbAbXLbclQlESW&P}uiKk-Uwp`jXeT$e$@5hz##?Niv z|Gng`FZ<>9ow;W%J-c1bg=;N&T6)E4hQj2>=eADx996ztd;M0cC(q}K_2o=|t@zMv zet+EZD!I;{R((FMwP@yq>-?4Xrns-0Jm+`J|F_1orrNFZo}w53Gv>Lq zqV8hnsg6v=e{HAbl_<{?SU9^T{MqM;@7jLe)6BgaW*i-5*S(W-vR=>jdsW%-@%C%8 zIPX;UXTD@)5lFDfwmJ+< z910B_3Ov!r{$APKb2XaDlP&Om#p7Ns&<+VV?dCJatIy|dziSq`@Bg0nd;U-4+m?Q5 zO5-O`bKF!p__vh~Xk!yC4s(DXiU4MSH%EYWNq~8@KD7a~s|K+#18yaqgki@sFgSqb zEd(4G(55TsDm)_sw8Qx6tJUk}Hh{*~pB@#DuerN-dU#yrQmcRBaTSRd-oM-P`P`=m z&HR18Oh4VJ>;-KiI8h5)e^B)I>-F6yKpWb!`fa~O%uMRiJaxwSd`#N>+HYC2t6r@< z9aDTZ6ttCK-@1FJi_hDJ@10)#ZfE+1_hvU!CVyI+Uz>epJ!sTCe0|~7&~V6sncUm>1;j|^zZNcdTs?K4$uys8>|y{{C>B4 z_5ApOVh@+pjwmxEi#s>8jyV%g?i%StoSs?OIXub#?sOd7tC|{|f)Qdv@Ngl~wb?K$-mK zVflXsDGVp=_-#Ho?EG+ud-ZeMPbZXDuM3~c-wq8d=?At zl-Wj<@34We{H5dxRJGb?J1kjXM*j1p7fsv z8m3OY7MZ?Q>)lF|^!=a#)2nryOh3OrY?qJwYd!VzpWnM)uUoBk547K>@_hBZV7tFB z{ilNmfB$~HzW$qc#*EBmGf$P>&JDl!v9El}=JR&DSDnz`dL;<76;U5FJ9}ElTPF~- zF`;mws_19)Jr8+PZ|82;eZ6*jTx8Kn)!l3pQm4mm)7kZ6(Np31KU}uHPwLhUYn=vK z>IFWI;8@?2pF&k_Tnek#?aBi0MFMR;&}!K8b#1=x`|A6@*;psE^n=FmK_ie)pH7co zSGVnc-S5z~KAE|vihb<=etFy!P&e(n>Hj~U!}o)>3eH%7z>k+bb)+SUEREC1g-Ul;cGf4lvkhpU9* z{=b{P|4*u@iIV7l-R*ZaRn^#hx!`>D^^>2%QbjT=k6hpPu{VE;>Ghc6m{!}hirqS! zJV5)wS1C;3s(x)T^=|q7z1a+&4WQQ7G23vb6Vvzqd75?L@0{Xuk*)J8o=kK<1=@cC znrWNI*=65$E$VR`g;mOPlD!ej^(aAuv~R9pF-)0&X)cyQ?|U@ ze~?W& zuKRx_*gup}<>`bE69YgeEcD!dFTVeWYh=d4R<=FcOL9-MFl0XTWbm8;jwzj830rTK zUXNX^7qK>o(SNDc)ThPgESInHQD;yQd6NDzs^sm~>tSmZ)8`breVwr-VhML>R9Mt@ zP*L!h$-}lz>5^*}XqUv6dM6*P*Qa&2-)TrbHKQ+&@yYA>|GygMr}~@(P076aU;lmg z{m?|kwThq(o0eZLI9Ky2NcvwAarpFq_uFmJ;5~jDg11jm*>YvA&)eu@Csi71{(YWb z-U!|iAJ6df-uHdscaP>zJ$U8ok2W5j`H5|ks}_8k*k7~cS7emdr}sSsLb zWtgP%Bt8F92G65=r)?BjyOz7~dTe><-APuxw@(~+o5!f~*gGm!p~jX?bz$baozL~$ zWlKY@{#{in@Hd0$M9t^3;eTz8&j|>7KfB1|fL8A7HkB`Q5_bZ+1SPclGhV&-4F>{6D8udsF@3 z!e9o=HyaMG@~S%4b!6}Nd(o{em;LScN*$hPC_9r;`%=-wgSYN7dBm-bllYKd|2z6> ztIpk{Su4M!E7Tkcepx7WZy}S%Yn=$m8S@KeEA9L3er1SyyxM5|a^1GWeAZJ+uSL2u zfHuLZy=M@*|L@!O)r~J+ew*S|a?E3iQ0(gT{j1pX(yy)lV|{nEm$&%JkQs$Pa=-=|v^6Q($({jEA3eJ^OM zz|vNT!!-VHKjwbSE5)ER`)I92(Q^CC-d2E-cL)x(LIA0?YtedEoWo|IEv| X?CceF=gBZIFfe$!`njxgN@xNA1;4tL literal 0 HcmV?d00001 diff --git a/backend/assets/test.png b/backend/assets/test.png new file mode 100644 index 0000000000000000000000000000000000000000..29250b8532bf5dbf8c93cb2be8be497a39962006 GIT binary patch literal 4624 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV0^&A#=yW3%g*_gfq{Xuz$3Dlfq`2Xgc%uT z&5>YW5Ip4R;uumf=k49<6Z0Q4A1~Z&nG+E`<8soiIhnWqNeG)3YfP)3ArrpHGT0;X zNx-M}XKQZssQ9eB*kKmvq;^co$n9pvRzv4EZ+?~D|7x@U`4-#iH#gJIe}DaT)$VPz z>woWh|9k)5-Tc?}SQ!`;Ql?*NWME)m;b348PqYmbTkA;Ltr!nhzNn&8-Kswk3ZHYD}6KV%#4kj^~D?hGcYn; zX!o6IwDirU(|Qx%_F2EXadSHNf%*mp#utax{O8@#6FSx-8T@YN^Ld{hb?Z<2`T6_f@9H>oNRiWMWysKiecTNS6KY zudmwvc0W^oKA&H|&Eng;-S6LtD4e(Xd`9eec(Lz%yS=4vuB;3$zYxlB;6JB8gI&&} zBb}u(G1YIk-tBvv*|25Dn@y)*ZM-Tw#cfm7*H=c_*VfFfm16iZ0p!GE{qpf;cT2Bt z%DK5oR!pH=Tz}p1xPpVMODBdruRd#f{mq-p3=7&>I2`259(Afu3--4yoqg=zZHARH zrB?!<&M7{p+52ea@_DP)`!_I{$bkYQG_m0Q-tX@|Fs%HiY@c|jfsC6fKR^5R?u6>}m>@pOClfC1 zum4~5j**$|N_OX+%I9-audWKcZE}5*s`shV>#^Z(y;7lNw{y4uV*Q&g1F$hCWy%;mDcBlGKitvqeWZ~te5`vwN7IiTR&E2$7)^U+n7`|8}@ z_xu0Xbw8G?c+gmOT-vUtB64?G?ruYAvm6a}`I>^izbl{5-F|iIO@HgRTdM9{TI#)9 zM%jTuYChN)QH4+knesb@U;kZR=6n0qjaR8(UKy^~c6^Q8`mEKvv@du?8RD!nTE-3um8!{|0&%5*x&Bwld|JBFPBWd_1CWW*_mti zkM+s!zPj_;`uO-Z9?78n;+GIwdl^>BHCuhVk^H*$`_1(En+hKv%lp3b>9puuvYUTx zx_WA=c65FHx0~s=|HiSNycVLrL_cD~g46d|I2;}eD=_#jGG}@+JAYs1-R)7idv+yX zynC%I@xZjVZ@1si`>wjSud(R&+wIf$|NFK2_;n4qPX!fn<@etzzh8S=GJVd+_1i3q zpXGctT${D}(9^5o@oz8w

N)rtZKn%O#)1dKq3?9G_vB{O{_{I|YY%zdkS5w*UX9IC5LgO|j#8vAeeT z%(2*bdFTD|`?YhAaUp`_M0Eb%tC@Y}_bQjW7u$TlQ~dRDX!iAWYyaD2Ut3dj+1I>u zH$#%BaBhF~yPeO=q!q&BYfJmj@ixo_6(*gTmzU+1H~jf{T>dN9i`?fDmPILlcU;*P zv7;dIX7&5Mw{3TYvYvQxaq;fE<%ipNzfRvC)n}b|XNO<1TOWzWk#9u@!o;uph> zpP-bNvAfOv^tTJn{9h~P^_45F%01M=nfmqB)#$tL|9-!}{`xPAe?K0-o{=G~a8>52 z*6X`FU#(gl)nECrRlMxi-qdx|-hR34|Ni4w#tp@s0u5>x%Xs^zt=*-4DcJtsk7P-k zih}9d)9wF$x%@VcZ@t{ys4W?RvbxM{JS*PM3wd2zBFY;cQ|P+=_&lr9tk)mY&d`h`DzaNyJWTK3*;%~3;>_3A*SFg>h0gu{ z?Cfmo-jzvPY zw$61`>TH|JqP^>mtqNUTW%B*ZOyk$0r?#8q-_AYKA^7w#zx|t{dE5GZLq7(%9@O zZ1uaKFGs@TYeUz^#M#DOy;{RASCQ~{=N(XD>MjmpEc)@VeY&^)-Yq@y_WSJG6A!mt zjo-QJ(<$xKGt=iq+J6c?da~F2-i^21@2|dJKBvfQdGX4)lK2e?j{WQS8*CVv9Ja4I zK0B`d@7G`NjyCh#Mbu|)|Me&|yx+4lHbJdZkZ4I@IM1AClh~N-c|hU zjAr(=H8-DqUfE}ze{at()!&lFX;b>`e!Yl~mu83@?JbOkz-S1JhQJ68fdxzc@Jm=J WUx}1n#Ld9Kz~JfX=d#Wzp$PyC7pUC; literal 0 HcmV?d00001 diff --git a/backend/bun.lock b/backend/bun.lock new file mode 100644 index 0000000..d1b97df --- /dev/null +++ b/backend/bun.lock @@ -0,0 +1,1410 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "terminus-backend", + "dependencies": { + "@nestjs/bullmq": "^10.0.1", + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/serve-static": "^4.0.0", + "@nestjs/swagger": "^7.1.17", + "@nestjs/terminus": "^10.2.0", + "@nestjs/throttler": "^5.1.0", + "@prisma/client": "^5.8.0", + "axios": "^1.13.5", + "bullmq": "^5.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "compression": "^1.7.4", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "joi": "^17.11.0", + "nest-winston": "^1.9.4", + "passport": "^0.7.0", + "puppeteer": "^23.11.0", + "qrcode": "^1.5.4", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1", + "sharp": "^0.33.1", + "winston": "^3.11.0", + }, + "devDependencies": { + "@eslint/js": "^9.16.0", + "@nestjs/cli": "^10.2.1", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.0", + "@types/bcrypt": "^5.0.2", + "@types/bun": "latest", + "@types/compression": "^1.7.5", + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^9.16.0", + "globals": "^15.13.0", + "prettier": "^3.1.1", + "prisma": "^5.8.0", + "supertest": "^6.3.3", + "ts-loader": "^9.5.4", + "typescript": "^5.3.3", + }, + }, + }, + "overrides": { + "basic-ftp": "^5.2.0", + "js-yaml": "^4.1.1", + "lodash": "^4.17.23", + "qs": "^6.14.1", + }, + "packages": { + "@angular-devkit/core": ["@angular-devkit/core@17.3.11", "", { "dependencies": { "ajv": "8.12.0", "ajv-formats": "2.1.1", "jsonc-parser": "3.2.1", "picomatch": "4.0.1", "rxjs": "7.8.1", "source-map": "0.7.4" }, "peerDependencies": { "chokidar": "^3.5.2" } }, "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ=="], + + "@angular-devkit/schematics": ["@angular-devkit/schematics@17.3.11", "", { "dependencies": { "@angular-devkit/core": "17.3.11", "jsonc-parser": "3.2.1", "magic-string": "0.30.8", "ora": "5.4.1", "rxjs": "7.8.1" } }, "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ=="], + + "@angular-devkit/schematics-cli": ["@angular-devkit/schematics-cli@17.3.11", "", { "dependencies": { "@angular-devkit/core": "17.3.11", "@angular-devkit/schematics": "17.3.11", "ansi-colors": "4.1.3", "inquirer": "9.2.15", "symbol-observable": "4.0.0", "yargs-parser": "21.1.1" }, "bin": { "schematics": "bin/schematics.js" } }, "sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], + + "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@ljharb/through": ["@ljharb/through@2.3.14", "", { "dependencies": { "call-bind": "^1.0.8" } }, "sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A=="], + + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], + + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@nestjs/bull-shared": ["@nestjs/bull-shared@10.2.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw=="], + + "@nestjs/bullmq": ["@nestjs/bullmq@10.2.3", "", { "dependencies": { "@nestjs/bull-shared": "^10.2.3", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, "sha512-Lo4W5kWD61/246Y6H70RNgV73ybfRbZyKKS4CBRDaMELpxgt89O+EgYZUB4pdoNrWH16rKcaT0AoVsB/iDztKg=="], + + "@nestjs/cli": ["@nestjs/cli@10.4.9", "", { "dependencies": { "@angular-devkit/core": "17.3.11", "@angular-devkit/schematics": "17.3.11", "@angular-devkit/schematics-cli": "17.3.11", "@nestjs/schematics": "^10.0.1", "chalk": "4.1.2", "chokidar": "3.6.0", "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.0.2", "glob": "10.4.5", "inquirer": "8.2.6", "node-emoji": "1.11.0", "ora": "5.4.1", "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", "typescript": "5.7.2", "webpack": "5.97.1", "webpack-node-externals": "3.0.0" }, "peerDependencies": { "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@swc/core": "^1.3.62" }, "optionalPeers": ["@swc/cli", "@swc/core"], "bin": { "nest": "bin/nest.js" } }, "sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA=="], + + "@nestjs/common": ["@nestjs/common@10.4.20", "", { "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" } }, "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ=="], + + "@nestjs/config": ["@nestjs/config@3.3.0", "", { "dependencies": { "dotenv": "16.4.5", "dotenv-expand": "10.0.0", "lodash": "4.17.21" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "rxjs": "^7.1.0" } }, "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA=="], + + "@nestjs/core": ["@nestjs/core@10.4.20", "", { "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "3.3.0", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/microservices": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/websockets": "^10.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/websockets"] }, "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ=="], + + "@nestjs/mapped-types": ["@nestjs/mapped-types@2.0.5", "", { "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0", "reflect-metadata": "^0.1.12 || ^0.2.0" } }, "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg=="], + + "@nestjs/passport": ["@nestjs/passport@10.0.3", "", { "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" } }, "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ=="], + + "@nestjs/platform-express": ["@nestjs/platform-express@10.4.20", "", { "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", "express": "4.21.2", "multer": "2.0.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0" } }, "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg=="], + + "@nestjs/schematics": ["@nestjs/schematics@10.2.3", "", { "dependencies": { "@angular-devkit/core": "17.3.11", "@angular-devkit/schematics": "17.3.11", "comment-json": "4.2.5", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "peerDependencies": { "typescript": ">=4.8.2" } }, "sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg=="], + + "@nestjs/serve-static": ["@nestjs/serve-static@4.0.2", "", { "dependencies": { "path-to-regexp": "0.2.5" }, "peerDependencies": { "@fastify/static": "^6.5.0 || ^7.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0", "express": "^4.18.1", "fastify": "^4.7.0" }, "optionalPeers": ["@fastify/static", "fastify"] }, "sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ=="], + + "@nestjs/swagger": ["@nestjs/swagger@7.4.2", "", { "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" }, "peerDependencies": { "@fastify/static": "^6.0.0 || ^7.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["@fastify/static"] }, "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ=="], + + "@nestjs/terminus": ["@nestjs/terminus@10.3.0", "", { "dependencies": { "boxen": "5.1.2", "check-disk-space": "3.4.0" }, "peerDependencies": { "@grpc/grpc-js": "*", "@grpc/proto-loader": "*", "@mikro-orm/core": "*", "@mikro-orm/nestjs": "*", "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0", "@nestjs/microservices": "^9.0.0 || ^10.0.0", "@nestjs/mongoose": "^9.0.0 || ^10.0.0", "@nestjs/sequelize": "^9.0.0 || ^10.0.0", "@nestjs/typeorm": "^9.0.0 || ^10.0.0", "@prisma/client": "*", "mongoose": "*", "reflect-metadata": "0.1.x || 0.2.x", "rxjs": "7.x", "sequelize": "*", "typeorm": "*" }, "optionalPeers": ["@grpc/grpc-js", "@grpc/proto-loader", "@mikro-orm/core", "@mikro-orm/nestjs", "@nestjs/axios", "@nestjs/microservices", "@nestjs/mongoose", "@nestjs/sequelize", "@nestjs/typeorm", "mongoose", "sequelize", "typeorm"] }, "sha512-vOJGCwt1OgrFuuxWQwPoaHqy9m9CfIk2qMUX2mosZLK5dFVJSEjHXrklkh3/Fw9PiUnfzvYFfiAdJRzUaxx+5Q=="], + + "@nestjs/testing": ["@nestjs/testing@10.4.20", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0", "@nestjs/platform-express": "^10.0.0" }, "optionalPeers": ["@nestjs/microservices"] }, "sha512-nMkRDukDKskdPruM6EsgMq7yJua+CPZM6I6FrLP8yXw8BiVSPv9Nm0CtcGGwt3kgZF9hfxKjGqLjsvVBsv6Vfw=="], + + "@nestjs/throttler": ["@nestjs/throttler@5.2.0", "", { "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "reflect-metadata": "^0.1.13 || ^0.2.0" } }, "sha512-G/G/MV3xf6sy1DwmnJsgeL+d2tQ/xGRNa9ZhZjm9Kyxp+3+ylGzwJtcnhWlN82PMEp3TiDQpTt+9waOIg/bpPg=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@nuxtjs/opencollective": ["@nuxtjs/opencollective@0.3.2", "", { "dependencies": { "chalk": "^4.1.0", "consola": "^2.15.0", "node-fetch": "^2.6.1" }, "bin": { "opencollective": "bin/opencollective.js" } }, "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA=="], + + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" } }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], + + "@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="], + + "@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="], + + "@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="], + + "@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@2.6.1", "", { "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg=="], + + "@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="], + + "@sideway/formula": ["@sideway/formula@3.0.1", "", {}, "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="], + + "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], + + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/compression": ["@types/compression@1.8.1", "", { "dependencies": { "@types/express": "*", "@types/node": "*" } }, "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], + + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + + "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], + + "@types/supertest": ["@types/supertest@6.0.3", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "@types/validator": ["@types/validator@13.15.10", "", {}, "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.1", "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.1", "@typescript-eslint/tsconfig-utils": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q=="], + + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], + + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + + "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "bare-fs": ["bare-fs@4.5.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw=="], + + "bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="], + + "bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.4", "", { "bin": "dist/cli.js" }, "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA=="], + + "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "body-parser": ["body-parser@1.20.3", "", { "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.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], + + "boxen": ["boxen@5.1.2", "", { "dependencies": { "ansi-align": "^3.0.0", "camelcase": "^6.2.0", "chalk": "^4.1.0", "cli-boxes": "^2.2.1", "string-width": "^4.2.2", "type-fest": "^0.20.2", "widest-line": "^3.1.0", "wrap-ansi": "^7.0.0" } }, "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bullmq": ["bullmq@5.65.1", "", { "dependencies": { "cron-parser": "^4.9.0", "ioredis": "^5.8.2", "msgpackr": "^1.11.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^11.1.0" } }, "sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001759", "", {}, "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + + "check-disk-space": ["check-disk-space@3.4.0", "", {}, "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + + "chromium-bidi": ["chromium-bidi@0.11.0", "", { "dependencies": { "mitt": "3.0.1", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA=="], + + "class-transformer": ["class-transformer@0.5.1", "", {}, "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="], + + "class-validator": ["class-validator@0.14.3", "", { "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", "validator": "^13.15.20" } }, "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA=="], + + "cli-boxes": ["cli-boxes@2.2.1", "", {}, "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw=="], + + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "cli-width": ["cli-width@3.0.0", "", {}, "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="], + + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "comment-json": ["comment-json@4.2.5", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1", "has-own-prop": "^2.0.0", "repeat-string": "^1.6.1" } }, "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw=="], + + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], + + "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + + "consola": ["consola@2.15.3", "", {}, "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw=="], + + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], + + "cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], + + "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1367902", "", {}, "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg=="], + + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + + "dotenv": ["dotenv@16.4.5", "", {}, "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg=="], + + "dotenv-expand": ["dotenv-expand@10.0.0", "", {}, "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "express": ["express@4.21.2", "", { "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.13.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" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], + + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": "cli.js" }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "file-type": ["file-type@20.4.1", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@1.3.1", "", { "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.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "fork-ts-checker-webpack-plugin": ["fork-ts-checker-webpack-plugin@9.0.2", "", { "dependencies": { "@babel/code-frame": "^7.16.7", "chalk": "^4.1.2", "chokidar": "^3.5.3", "cosmiconfig": "^8.2.0", "deepmerge": "^4.2.2", "fs-extra": "^10.0.0", "memfs": "^3.4.1", "minimatch": "^3.0.4", "node-abort-controller": "^3.0.1", "schema-utils": "^3.1.1", "semver": "^7.3.5", "tapable": "^2.2.1" }, "peerDependencies": { "typescript": ">3.6.0", "webpack": "^5.11.0" } }, "sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formidable": ["formidable@2.1.5", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0", "qs": "^6.11.0" } }, "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + + "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-own-prop": ["has-own-prop@2.0.0", "", {}, "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "helmet": ["helmet@7.2.0", "", {}, "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inquirer": ["inquirer@8.2.6", "", { "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg=="], + + "ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterare": ["iterare@1.2.1", "", {}, "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + + "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "libphonenumber-js": ["libphonenumber-js@1.12.31", "", {}, "sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="], + + "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@2.6.0", "", { "bin": "cli.js" }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": "bin/cmd.js" }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "multer": ["multer@2.0.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw=="], + + "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + + "nest-winston": ["nest-winston@1.10.2", "", { "dependencies": { "fast-safe-stringify": "^2.1.1" }, "peerDependencies": { "@nestjs/common": "^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "winston": "^3.0.0" } }, "sha512-Z9IzL/nekBOF/TEwBHUJDiDPMaXUcFquUQOFavIRet6xF0EbuWnOzslyN/ksgzG+fITNgXhMdrL/POp9SdaFxA=="], + + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + + "node-emoji": ["node-emoji@1.11.0", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "passport": ["passport@0.7.0", "", { "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", "utils-merge": "^1.0.1" } }, "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ=="], + + "passport-strategy": ["passport-strategy@1.0.0", "", {}, "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "path-to-regexp": ["path-to-regexp@3.3.0", "", {}, "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "pause": ["pause@0.0.1", "", {}, "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.1", "", {}, "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg=="], + + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.7.4", "", { "bin": "bin/prettier.cjs" }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + + "prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": "build/index.js" }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "puppeteer": ["puppeteer@23.11.1", "", { "dependencies": { "@puppeteer/browsers": "2.6.1", "chromium-bidi": "0.11.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1367902", "puppeteer-core": "23.11.1", "typed-query-selector": "^2.12.0" }, "bin": "lib/cjs/puppeteer/node/cli.js" }, "sha512-53uIX3KR5en8l7Vd8n5DUv90Ae9QDQsyIthaUFVzwV6yU750RjqRznEtNMBT20VthqAdemnJN+hxVdmMHKt7Zw=="], + + "puppeteer-core": ["puppeteer-core@23.11.1", "", { "dependencies": { "@puppeteer/browsers": "2.6.1", "chromium-bidi": "0.11.0", "debug": "^4.4.0", "devtools-protocol": "0.0.1367902", "typed-query-selector": "^2.12.0", "ws": "^8.18.0" } }, "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg=="], + + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "reflect-metadata": ["reflect-metadata@0.1.14", "", {}, "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A=="], + + "repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + + "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + + "semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], + + "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + + "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "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" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "superagent": ["superagent@8.1.2", "", { "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.4", "debug": "^4.3.4", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.0", "formidable": "^2.1.2", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.11.0", "semver": "^7.3.8" } }, "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA=="], + + "supertest": ["supertest@6.3.4", "", { "dependencies": { "methods": "^1.1.2", "superagent": "^8.1.2" } }, "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "swagger-ui-dist": ["swagger-ui-dist@5.17.14", "", {}, "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw=="], + + "symbol-observable": ["symbol-observable@4.0.0", "", {}, "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="], + + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + + "terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": "bin/terser" }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.3.15", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ=="], + + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "ts-loader": ["ts-loader@9.5.4", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tsconfig-paths-webpack-plugin": ["tsconfig-paths-webpack-plugin@4.2.0", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.7.0", "tapable": "^2.2.1", "tsconfig-paths": "^4.1.2" } }, "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "typed-query-selector": ["typed-query-selector@2.12.0", "", {}, "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uid": ["uid@2.0.2", "", { "dependencies": { "@lukeed/csprng": "^1.0.0" } }, "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "validator": ["validator@13.15.23", "", {}, "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="], + + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "webpack": ["webpack@5.97.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.14.0", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.10", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": "bin/webpack.js" }, "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg=="], + + "webpack-node-externals": ["webpack-node-externals@3.0.0", "", {}, "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ=="], + + "webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + + "widest-line": ["widest-line@3.1.0", "", { "dependencies": { "string-width": "^4.0.0" } }, "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg=="], + + "winston": ["winston@3.18.3", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], + + "@angular-devkit/core/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + + "@angular-devkit/core/jsonc-parser": ["jsonc-parser@3.2.1", "", {}, "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA=="], + + "@angular-devkit/core/rxjs": ["rxjs@7.8.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg=="], + + "@angular-devkit/schematics/jsonc-parser": ["jsonc-parser@3.2.1", "", {}, "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA=="], + + "@angular-devkit/schematics/rxjs": ["rxjs@7.8.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg=="], + + "@angular-devkit/schematics-cli/inquirer": ["inquirer@9.2.15", "", { "dependencies": { "@ljharb/through": "^2.3.12", "ansi-escapes": "^4.3.2", "chalk": "^5.3.0", "cli-cursor": "^3.1.0", "cli-width": "^4.1.0", "external-editor": "^3.1.0", "figures": "^3.2.0", "lodash": "^4.17.21", "mute-stream": "1.0.0", "ora": "^5.4.1", "run-async": "^3.0.0", "rxjs": "^7.8.1", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wrap-ansi": "^6.2.0" } }, "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@nestjs/cli/typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="], + + "@nestjs/serve-static/path-to-regexp": ["path-to-regexp@0.2.5", "", {}, "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q=="], + + "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "@so-ric/colorspace/color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], + + "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "ajv-formats/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "boxen/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cli-table3/@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], + + "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "fork-ts-checker-webpack-plugin/cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], + + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "send/mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "@angular-devkit/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@angular-devkit/schematics-cli/inquirer/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "@angular-devkit/schematics-cli/inquirer/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "@angular-devkit/schematics-cli/inquirer/mute-stream": ["mute-stream@1.0.0", "", {}, "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA=="], + + "@angular-devkit/schematics-cli/inquirer/run-async": ["run-async@3.0.0", "", {}, "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@puppeteer/browsers/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@puppeteer/browsers/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "@so-ric/colorspace/color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "@so-ric/colorspace/color/color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + + "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@so-ric/colorspace/color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "@so-ric/colorspace/color/color-string/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + } +} diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..660233c --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Create all required directories +mkdir -p /app/uploads/screens +mkdir -p /app/uploads/firmware +mkdir -p /app/uploads/widgets +mkdir -p /app/uploads/captures +mkdir -p /app/uploads/drawings +mkdir -p /app/logs + +# Run database migrations (ignore errors on fresh install) +bunx prisma db push --skip-generate 2>&1 || echo "Warning: prisma db push failed (may need manual migration)" + +# Execute the main command +exec "$@" diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..bcb101f --- /dev/null +++ b/backend/eslint.config.js @@ -0,0 +1,44 @@ +const js = require('@eslint/js'); +const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const tsParser = require('@typescript-eslint/parser'); +const globals = require('globals'); + +module.exports = [ + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + globals: { + ...globals.node, + ...globals.jest, + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'no-unused-vars': 'off', + }, + }, + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.js'], + }, +]; diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..6de5e34 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "webpack": true + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..66b21e6 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,11518 @@ +{ + "name": "terminus-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "terminus-backend", + "version": "1.0.0", + "dependencies": { + "@nestjs/bull": "^10.0.1", + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/serve-static": "^4.0.0", + "@nestjs/swagger": "^7.1.17", + "@nestjs/terminus": "^10.2.0", + "@nestjs/throttler": "^5.1.0", + "@prisma/client": "^5.8.0", + "axios": "^1.13.2", + "bcrypt": "^5.1.1", + "bull": "^4.12.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "compression": "^1.7.4", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "joi": "^17.11.0", + "nest-winston": "^1.9.4", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "puppeteer": "^23.11.0", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1", + "sharp": "^0.33.1", + "winston": "^3.11.0" + }, + "devDependencies": { + "@eslint/js": "^9.16.0", + "@nestjs/cli": "^10.2.1", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.0", + "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.7.5", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.11", + "@types/node": "^20.10.6", + "@types/passport-jwt": "^4.0.0", + "@types/passport-local": "^1.0.38", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^9.16.0", + "globals": "^15.13.0", + "jest": "^29.7.0", + "prettier": "^3.1.1", + "prisma": "^5.8.0", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@angular-devkit/core": { + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.1", + "picomatch": "4.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", + "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.11", + "jsonc-parser": "3.2.1", + "magic-string": "0.30.8", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-17.3.11.tgz", + "integrity": "sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", + "ansi-colors": "4.1.3", + "inquirer": "9.2.15", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": { + "version": "9.2.15", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.15.tgz", + "integrity": "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==", + "dev": true, + "dependencies": { + "@ljharb/through": "^2.3.12", + "ansi-escapes": "^4.3.2", + "chalk": "^5.3.0", + "cli-cursor": "^3.1.0", + "cli-width": "^4.1.0", + "external-editor": "^3.1.0", + "figures": "^3.2.0", + "lodash": "^4.17.21", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@borewit/text-codec": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", + "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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==", + "dev": true + }, + "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==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@ljharb/through": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", + "integrity": "sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nestjs/bull": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-10.2.3.tgz", + "integrity": "sha512-Gy90JjFCfYhWFBeoBSidc7rEEf2BNhkJ3RfK8ym589POOldwAra2xcnFBi0ZuhhOV60GcrCJBBkdrUbAMM670w==", + "dependencies": { + "@nestjs/bull-shared": "^10.2.3", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "bull": "^3.3 || ^4.0.0" + } + }, + "node_modules/@nestjs/bull-shared": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.3.tgz", + "integrity": "sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw==", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/cli": { + "version": "10.4.9", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", + "integrity": "sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", + "@angular-devkit/schematics-cli": "17.3.11", + "@nestjs/schematics": "^10.0.1", + "chalk": "4.1.2", + "chokidar": "3.6.0", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.0.2", + "glob": "10.4.5", + "inquirer": "8.2.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tree-kill": "1.2.2", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.7.2", + "webpack": "5.97.1", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 16.14" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/cli/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nestjs/cli/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@nestjs/cli/node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@nestjs/cli/node_modules/webpack": { + "version": "5.97.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", + "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", + "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", + "dependencies": { + "file-type": "20.4.1", + "iterare": "1.2.1", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.3.0.tgz", + "integrity": "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==", + "dependencies": { + "dotenv": "16.4.5", + "dotenv-expand": "10.0.0", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz", + "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", + "hasInstallScript": true, + "dependencies": { + "@nuxtjs/opencollective": "0.3.2", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "3.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "dependencies": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", + "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", + "dependencies": { + "body-parser": "1.20.3", + "cors": "2.8.5", + "express": "4.21.2", + "multer": "2.0.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", + "integrity": "sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", + "comment-json": "4.2.5", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "typescript": ">=4.8.2" + } + }, + "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true + }, + "node_modules/@nestjs/serve-static": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.2.tgz", + "integrity": "sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==", + "license": "MIT", + "dependencies": { + "path-to-regexp": "0.2.5" + }, + "peerDependencies": { + "@fastify/static": "^6.5.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "express": "^4.18.1", + "fastify": "^4.7.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + } + }, + "node_modules/@nestjs/serve-static/node_modules/path-to-regexp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz", + "integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==", + "license": "MIT" + }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/terminus": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.3.0.tgz", + "integrity": "sha512-vOJGCwt1OgrFuuxWQwPoaHqy9m9CfIk2qMUX2mosZLK5dFVJSEjHXrklkh3/Fw9PiUnfzvYFfiAdJRzUaxx+5Q==", + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x || 0.2.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/@nestjs/testing": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", + "integrity": "sha512-nMkRDukDKskdPruM6EsgMq7yJua+CPZM6I6FrLP8yXw8BiVSPv9Nm0CtcGGwt3kgZF9hfxKjGqLjsvVBsv6Vfw==", + "dev": true, + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@nestjs/throttler": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-5.2.0.tgz", + "integrity": "sha512-G/G/MV3xf6sy1DwmnJsgeL+d2tQ/xGRNa9ZhZjm9Kyxp+3+ylGzwJtcnhWlN82PMEp3TiDQpTt+9waOIg/bpPg==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nuxtjs/opencollective": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", + "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.0", + "node-fetch": "^2.6.1" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "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==", + "dev": true + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "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==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "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==" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "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" + } + ] + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", + "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "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.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "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" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/bull": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", + "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", + "dependencies": { + "cron-parser": "^4.9.0", + "get-port": "^5.1.1", + "ioredis": "^5.3.2", + "lodash": "^4.17.21", + "msgpackr": "^1.11.2", + "semver": "^7.5.2", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "engines": { + "node": ">=16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", + "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", + "dev": true, + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1", + "has-own-prop": "^2.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, + "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==", + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "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==", + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1367902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "license": "BSD-3-Clause" + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "engines": { + "node": ">=12" + } + }, + "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==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "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/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "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==", + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "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==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "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==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "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.13.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/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/express/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==" + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "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.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.0.2.tgz", + "integrity": "sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=12.13.0", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "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==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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==", + "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-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "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==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "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==", + "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" + } + ] + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "dependencies": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.31", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz", + "integrity": "sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "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==", + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "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==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "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==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "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==" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/nest-winston": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/nest-winston/-/nest-winston-1.10.2.tgz", + "integrity": "sha512-Z9IzL/nekBOF/TEwBHUJDiDPMaXUcFquUQOFavIRet6xF0EbuWnOzslyN/ksgzG+fITNgXhMdrL/POp9SdaFxA==", + "dependencies": { + "fast-safe-stringify": "^2.1.1" + }, + "peerDependencies": { + "@nestjs/common": "^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "winston": "^3.0.0" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "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==", + "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==", + "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==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "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==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "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==" + }, + "node_modules/picomatch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer": { + "version": "23.11.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-23.11.1.tgz", + "integrity": "sha512-53uIX3KR5en8l7Vd8n5DUv90Ae9QDQsyIthaUFVzwV6yU750RjqRznEtNMBT20VthqAdemnJN+hxVdmMHKt7Zw==", + "deprecated": "< 24.15.0 is no longer supported", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1367902", + "puppeteer-core": "23.11.1", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "23.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", + "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "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==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==" + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "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/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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" + } + ] + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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==", + "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==", + "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==", + "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==", + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "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==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.15.tgz", + "integrity": "sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/terser/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "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/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", + "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", + "dependencies": { + "@borewit/text-codec": "^0.1.0", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-loader": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "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==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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==" + }, + "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==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "engines": { + "node": ">= 0.10" + } + }, + "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/webpack": { + "version": "5.103.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", + "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "dev": true, + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.26.3", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", + "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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==" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..0cdd29c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,81 @@ +{ + "name": "inker-backend", + "version": "0.1.0", + "description": "Inker Server Backend - E-ink Device Management", + "main": "dist/main.js", + "scripts": { + "build": "bun run nest build", + "start": "bun run nest start", + "start:dev": "bun --watch run src/main.ts", + "start:debug": "bun --watch --inspect run src/main.ts", + "start:prod": "bun run dist/main.js", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "bun test", + "test:watch": "bun test --watch", + "test:cov": "bun test --coverage", + "prisma:generate": "bunx prisma generate", + "prisma:migrate": "bunx prisma migrate dev", + "prisma:studio": "bunx prisma studio", + "prisma:seed": "bun run prisma/seed.ts", + "db:seed": "bun run prisma:seed" + }, + "prisma": { + "seed": "bun run prisma/seed.ts" + }, + "dependencies": { + "@nestjs/bullmq": "^10.0.1", + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/serve-static": "^4.0.0", + "@nestjs/swagger": "^7.1.17", + "@nestjs/terminus": "^10.2.0", + "@nestjs/throttler": "^5.1.0", + "@prisma/client": "^5.8.0", + "axios": "^1.13.5", + "bullmq": "^5.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "compression": "^1.7.4", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "joi": "^17.11.0", + "nest-winston": "^1.9.4", + "passport": "^0.7.0", + "puppeteer": "^23.11.0", + "qrcode": "^1.5.4", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1", + "sharp": "^0.33.1", + "winston": "^3.11.0" + }, + "devDependencies": { + "@eslint/js": "^9.16.0", + "@nestjs/cli": "^10.2.1", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.0", + "@types/bcrypt": "^5.0.2", + "@types/bun": "latest", + "@types/compression": "^1.7.5", + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^9.16.0", + "globals": "^15.13.0", + "prettier": "^3.1.1", + "prisma": "^5.8.0", + "supertest": "^6.3.3", + "ts-loader": "^9.5.4", + "typescript": "^5.3.3" + }, + "overrides": { + "qs": "^6.14.1", + "basic-ftp": "^5.2.0", + "lodash": "^4.17.23", + "js-yaml": "^4.1.1" + } +} diff --git a/backend/prisma/migrations/20251221184518_remove_user_auth/migration.sql b/backend/prisma/migrations/20251221184518_remove_user_auth/migration.sql new file mode 100644 index 0000000..6ff62df --- /dev/null +++ b/backend/prisma/migrations/20251221184518_remove_user_auth/migration.sql @@ -0,0 +1,20 @@ +-- Remove user-related foreign keys and columns +-- This migration removes user authentication system in favor of PIN-based auth + +-- 1. Drop foreign key constraints that reference users table +ALTER TABLE "devices" DROP CONSTRAINT IF EXISTS "devices_user_id_fkey"; +ALTER TABLE "screens" DROP CONSTRAINT IF EXISTS "screens_user_id_fkey"; +ALTER TABLE "playlists" DROP CONSTRAINT IF EXISTS "playlists_user_id_fkey"; +ALTER TABLE "screen_designs" DROP CONSTRAINT IF EXISTS "screen_designs_user_id_fkey"; + +-- 2. Drop user_id columns from tables +ALTER TABLE "devices" DROP COLUMN IF EXISTS "user_id"; +ALTER TABLE "screens" DROP COLUMN IF EXISTS "user_id"; +ALTER TABLE "playlists" DROP COLUMN IF EXISTS "user_id"; +ALTER TABLE "screen_designs" DROP COLUMN IF EXISTS "user_id"; + +-- 3. Drop refresh_tokens table +DROP TABLE IF EXISTS "refresh_tokens"; + +-- 4. Drop users table +DROP TABLE IF EXISTS "users"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..e595e4f --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,296 @@ +// Inker Database Schema +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Device { + id Int @id @default(autoincrement()) + name String @map("label") + friendlyId String? @map("friendly_id") + macAddress String @unique @map("mac_address") + apiKey String @unique @map("api_key") + firmwareVersion String? @map("firmware_version") + modelId Int? @map("model_id") + playlistId Int? @map("playlist_id") + isActive Boolean @default(true) @map("is_active") + wifi Int @default(0) + battery Float @default(0) + refreshRate Int @default(900) @map("refresh_rate") + imageTimeout Int @default(0) @map("image_timeout") + width Int @default(0) + height Int @default(0) + proxy Boolean @default(false) + firmwareUpdate Boolean @default(true) @map("firmware_update") + sleepStartAt String? @map("sleep_start_at") + sleepStopAt String? @map("sleep_stop_at") + lastSeenAt DateTime? @map("last_seen_at") + refreshPending Boolean @default(false) @map("refresh_pending") + lastScreenId String? @map("last_screen_id") // Track last displayed screen for ghosting prevention + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + model Model? @relation(fields: [modelId], references: [id]) + playlist Playlist? @relation(fields: [playlistId], references: [id]) + logs DeviceLog[] + screenAssignments DeviceScreenAssignment[] + + @@map("devices") +} + +model Model { + id Int @id @default(autoincrement()) + name String @unique + label String + width Int + height Int + description String? + mimeType String @default("image/png") @map("mime_type") + colors Int @default(2) + bitDepth Int @default(1) @map("bit_depth") + rotation Int @default(0) + offsetX Int @default(0) @map("offset_x") + offsetY Int @default(0) @map("offset_y") + kind String @default("terminus") + scaleFactor Float @default(1.0) @map("scale_factor") + publishedAt DateTime? @map("published_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + devices Device[] + screens Screen[] + + @@map("models") +} + +model Screen { + id Int @id @default(autoincrement()) + name String + description String? + imageUrl String @map("image_url") + thumbnailUrl String? @map("thumbnail_url") + modelId Int @map("model_id") + isPublic Boolean @default(false) @map("is_public") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + model Model @relation(fields: [modelId], references: [id]) + playlistItems PlaylistItem[] + + @@map("screens") +} + +model Playlist { + id Int @id @default(autoincrement()) + name String + description String? + isActive Boolean @default(false) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + items PlaylistItem[] + devices Device[] + + @@map("playlists") +} + +model PlaylistItem { + id Int @id @default(autoincrement()) + playlistId Int @map("playlist_id") + screenId Int? @map("screen_id") + screenDesignId Int? @map("screen_design_id") + order Int @default(0) + duration Int @default(60) // seconds + createdAt DateTime @default(now()) @map("created_at") + + playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) + screen Screen? @relation(fields: [screenId], references: [id], onDelete: Cascade) + screenDesign ScreenDesign? @relation(fields: [screenDesignId], references: [id], onDelete: Cascade) + + @@map("playlist_items") +} + +model Extension { + id Int @id @default(autoincrement()) + name String + description String? + type String // webhook, polling, custom + config Json // JSON configuration + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("extensions") +} + +// ======================== +// Data Source & Custom Widget Models +// ======================== + +// Data Source - external API or RSS feed configuration +model DataSource { + id Int @id @default(autoincrement()) + name String + description String? + type String // "json" | "rss" + url String // API endpoint or RSS feed URL + method String @default("GET") // HTTP method for JSON APIs + headers Json? // Custom headers (e.g., API keys) + refreshInterval Int @default(300) @map("refresh_interval") // Seconds between fetches + jsonPath String? @map("json_path") // JSONPath expression to extract data + isActive Boolean @default(true) @map("is_active") + lastFetchedAt DateTime? @map("last_fetched_at") + lastData Json? @map("last_data") // Cached response data + lastError String? @map("last_error") // Last fetch error message + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + customWidgets CustomWidget[] + + @@map("data_sources") +} + +// Custom Widget - user-defined widget using data from a DataSource +model CustomWidget { + id Int @id @default(autoincrement()) + name String + description String? + dataSourceId Int @map("data_source_id") + displayType String // "template" | "value" | "list" | "title-value" + template String? // Template string for "template" display type + config Json @default("{}") // Layout config (fontSize, alignment, etc.) + minWidth Int @default(100) @map("min_width") + minHeight Int @default(50) @map("min_height") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + dataSource DataSource @relation(fields: [dataSourceId], references: [id], onDelete: Cascade) + + @@map("custom_widgets") +} + +model Firmware { + id Int @id @default(autoincrement()) + version String @unique + downloadUrl String @map("download_url") + releaseNotes String? @map("release_notes") + isStable Boolean @default(false) @map("is_stable") + createdAt DateTime @default(now()) @map("created_at") + + @@map("firmware") +} + +model DeviceLog { + id Int @id @default(autoincrement()) + deviceId Int @map("device_id") + level String // info, warning, error + message String + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) + + @@map("device_logs") +} + +// ======================== +// Screen Designer Models +// ======================== + +// Widget Template - defines available widget types +model WidgetTemplate { + id Int @id @default(autoincrement()) + name String @unique // e.g., "clock", "weather", "text" + label String // Display name: "Live Clock", "Weather" + description String? + category String // "time", "weather", "content", "system" + defaultConfig Json // Default configuration for this widget type + minWidth Int @default(100) @map("min_width") + minHeight Int @default(50) @map("min_height") + createdAt DateTime @default(now()) @map("created_at") + + widgets ScreenWidget[] + + @@map("widget_templates") +} + +// Screen Design - a designed screen with widgets +model ScreenDesign { + id Int @id @default(autoincrement()) + name String + description String? + width Int @default(800) + height Int @default(480) + background String @default("#FFFFFF") // white for e-ink + isTemplate Boolean @default(false) @map("is_template") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + widgets ScreenWidget[] + assignments DeviceScreenAssignment[] + playlistItems PlaylistItem[] + + @@map("screen_designs") +} + +// Widget instance on a screen +model ScreenWidget { + id Int @id @default(autoincrement()) + screenDesignId Int @map("screen_design_id") + templateId Int @map("template_id") + x Int @default(0) + y Int @default(0) + width Int @default(200) + height Int @default(100) + rotation Int @default(0) // Rotation in degrees (0, 90, 180, 270, or any angle) + config Json // Widget-specific config (timezone, location, etc.) + zIndex Int @default(0) @map("z_index") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + screenDesign ScreenDesign @relation(fields: [screenDesignId], references: [id], onDelete: Cascade) + template WidgetTemplate @relation(fields: [templateId], references: [id]) + + @@map("screen_widgets") +} + +// Device to Screen Design assignment +model DeviceScreenAssignment { + id Int @id @default(autoincrement()) + deviceId Int @map("device_id") + screenDesignId Int @map("screen_design_id") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) + screenDesign ScreenDesign @relation(fields: [screenDesignId], references: [id], onDelete: Cascade) + + @@unique([deviceId, screenDesignId]) + @@map("device_screen_assignments") +} + +// Application Settings - key/value store for configuration +model Setting { + id Int @id @default(autoincrement()) + key String @unique + value String // Stored as encrypted string for sensitive values + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("settings") +} + +// Blocked devices - prevents auto-provisioning of deleted devices +model BlockedDevice { + id Int @id @default(autoincrement()) + macAddress String @unique @map("mac_address") + reason String? // Why device was blocked (e.g., "deleted by admin") + createdAt DateTime @default(now()) @map("created_at") + + @@map("blocked_devices") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..dee3c62 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,354 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +/** + * Default widget templates for the Screen Designer + */ +const WIDGET_TEMPLATES = [ + { + name: 'clock', + label: 'Live Clock', + description: 'Displays current time with configurable timezone', + category: 'time', + defaultConfig: { + timezone: 'local', + format: '24h', + showSeconds: false, + showDate: true, + dateFormat: 'YYYY-MM-DD', + fontFamily: 'monospace', + fontSize: 48, + }, + minWidth: 200, + minHeight: 80, + }, + { + name: 'weather', + label: 'Weather', + description: 'Shows current weather for a location', + category: 'weather', + defaultConfig: { + location: 'New York, US', + latitude: 40.7128, + longitude: -74.006, + units: 'metric', + showIcon: true, + showTemperature: true, + showCondition: true, + showHumidity: false, + fontSize: 32, + }, + minWidth: 200, + minHeight: 150, + }, + { + name: 'text', + label: 'Text Block', + description: 'Static text content', + category: 'content', + defaultConfig: { + text: 'Hello World', + fontFamily: 'sans-serif', + fontSize: 24, + fontWeight: 'normal', + textAlign: 'left', + color: '#000000', + }, + minWidth: 100, + minHeight: 40, + }, + { + name: 'date', + label: 'Date Display', + description: 'Shows current date', + category: 'time', + defaultConfig: { + timezone: 'local', + format: 'dddd, MMMM D, YYYY', + fontSize: 24, + fontFamily: 'sans-serif', + color: '#000000', + textAlign: 'center', + }, + minWidth: 250, + minHeight: 50, + }, + { + name: 'qrcode', + label: 'QR Code', + description: 'Generates a QR code from text/URL', + category: 'content', + defaultConfig: { + content: 'https://example.com', + size: 150, + errorCorrection: 'M', + darkColor: '#000000', + lightColor: '#FFFFFF', + }, + minWidth: 100, + minHeight: 100, + }, + { + name: 'battery', + label: 'Battery Status', + description: 'Shows device battery level', + category: 'system', + defaultConfig: { + showPercentage: true, + showIcon: true, + fontSize: 18, + iconSize: 24, + color: '#000000', + }, + minWidth: 80, + minHeight: 40, + }, + { + name: 'countdown', + label: 'Countdown Timer', + description: 'Counts down to a specific date/time', + category: 'time', + defaultConfig: { + targetDate: '2025-01-01T00:00:00Z', + label: 'New Year', + showDays: true, + showHours: true, + showMinutes: true, + showSeconds: false, + fontSize: 32, + fontFamily: 'monospace', + color: '#000000', + }, + minWidth: 250, + minHeight: 80, + }, + { + name: 'image', + label: 'Static Image', + description: 'Displays a static image from URL', + category: 'content', + defaultConfig: { + url: '', + fit: 'contain', + backgroundColor: '#FFFFFF', + }, + minWidth: 50, + minHeight: 50, + }, + { + name: 'divider', + label: 'Divider Line', + description: 'A horizontal or vertical divider line', + category: 'layout', + defaultConfig: { + orientation: 'horizontal', + color: '#000000', + thickness: 2, + style: 'solid', + }, + minWidth: 20, + minHeight: 2, + }, + { + name: 'rectangle', + label: 'Rectangle', + description: 'A simple rectangle shape', + category: 'layout', + defaultConfig: { + fillColor: '#000000', + borderColor: '#000000', + borderWidth: 0, + borderRadius: 0, + }, + minWidth: 20, + minHeight: 20, + }, + { + name: 'wifi', + label: 'WiFi Status', + description: 'Shows WiFi signal strength', + category: 'system', + defaultConfig: { + showStrength: true, + showIcon: true, + fontSize: 18, + iconSize: 24, + color: '#000000', + }, + minWidth: 80, + minHeight: 40, + }, + { + name: 'deviceinfo', + label: 'Device Info', + description: 'Shows device name and status', + category: 'system', + defaultConfig: { + showName: true, + showStatus: true, + showLastSeen: false, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000000', + }, + minWidth: 150, + minHeight: 60, + }, + { + name: 'daysuntil', + label: 'Days Until', + description: 'Shows days remaining until an event', + category: 'time', + defaultConfig: { + eventName: 'Christmas', + targetDate: '2025-12-25', + fontSize: 32, + fontFamily: 'sans-serif', + color: '#000000', + showLabel: true, + labelPosition: 'before', + }, + minWidth: 200, + minHeight: 60, + }, + { + name: 'github', + label: 'GitHub Stars', + description: 'Displays star count for a GitHub repository', + category: 'content', + defaultConfig: { + owner: 'facebook', + repo: 'react', + showIcon: true, + showRepoName: false, + fontSize: 32, + fontFamily: 'sans-serif', + }, + minWidth: 100, + minHeight: 50, + }, + { + name: 'custom-widget-base', + label: 'Custom Widget', + description: 'Base template for user-created custom widgets', + category: 'custom', + defaultConfig: { + customWidgetId: null, + displayType: 'value', + fontSize: 24, + }, + minWidth: 100, + minHeight: 50, + }, +]; + +/** + * Database seed script + * Creates initial device models and widget templates + */ +async function main() { + console.log('🌱 Starting database seed...'); + + // Create device models + const models = [ + { + name: 'og_png', + label: 'TRMNL Original (PNG)', + width: 800, + height: 480, + description: 'Original TRMNL device with 800x480 e-ink display (PNG format)', + mimeType: 'image/png', + colors: 2, + bitDepth: 1, + rotation: 0, + offsetX: 0, + offsetY: 0, + kind: 'terminus', + scaleFactor: 1.0, + }, + { + name: 'og_bmp', + label: 'TRMNL Original (BMP)', + width: 800, + height: 480, + description: 'Original TRMNL device with 800x480 e-ink display (BMP format)', + mimeType: 'image/bmp', + colors: 2, + bitDepth: 1, + rotation: 0, + offsetX: 0, + offsetY: 0, + kind: 'terminus', + scaleFactor: 1.0, + }, + { + name: 'large_png', + label: 'TRMNL Large (PNG)', + width: 1200, + height: 825, + description: 'Large TRMNL device with 1200x825 e-ink display (PNG format)', + mimeType: 'image/png', + colors: 2, + bitDepth: 1, + rotation: 0, + offsetX: 0, + offsetY: 0, + kind: 'terminus', + scaleFactor: 1.0, + }, + ]; + + for (const modelData of models) { + await prisma.model.upsert({ + where: { name: modelData.name }, + update: {}, + create: modelData, + }); + } + + console.log(`✅ Created ${models.length} device models`); + + // Create sample firmware + const firmware = await prisma.firmware.upsert({ + where: { version: '1.0.0' }, + update: {}, + create: { + version: '1.0.0', + downloadUrl: 'https://example.com/firmware/v1.0.0.bin', + releaseNotes: 'Initial stable release', + isStable: true, + }, + }); + + console.log(`✅ Created firmware version: ${firmware.version}`); + + // Seed widget templates + console.log('Seeding widget templates...'); + let widgetTemplatesCreated = 0; + for (const template of WIDGET_TEMPLATES) { + await prisma.widgetTemplate.upsert({ + where: { name: template.name }, + update: {}, + create: template, + }); + widgetTemplatesCreated++; + } + console.log(`✅ Created ${widgetTemplatesCreated} widget templates`); + + console.log(''); + console.log('🎉 Database seeding completed!'); + console.log(''); + console.log('📝 PIN Authentication:'); + console.log(` Default PIN: 1111`); + console.log(''); +} + +main() + .catch((e) => { + console.error('❌ Error seeding database:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/api/api.controller.test.ts b/backend/src/api/api.controller.test.ts new file mode 100644 index 0000000..0e395fa --- /dev/null +++ b/backend/src/api/api.controller.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { ApiController } from './api.controller'; +import { DisplayService } from './display/display.service'; +import { DefaultScreenService } from './display/default-screen.service'; +import { SetupService } from './setup/setup.service'; +import { LogService } from './log/log.service'; +import { ScreenRendererService } from '../screen-designer/services/screen-renderer.service'; +import { ConfigService } from '@nestjs/config'; + +describe('ApiController (e2e)', () => { + let app: INestApplication; + + const mockDisplayService = { + getDisplayContent: async () => ({ + status: 200, + image_url: 'http://localhost:3002/test.png', + firmware_url: null, + refresh_rate: 900, + }), + }; + + const mockSetupService = { + provisionDevice: async () => ({ + api_key: 'test-api-key', + friendly_id: 'test-device-1', + image_url: 'http://localhost:3002/setup.bmp', + message: 'Welcome to Inker!', + }), + }; + + const mockLogService = { + createLog: async () => ({ + status: 'ok', + }), + }; + + const mockScreenRendererService = { + renderScreenDesign: async () => Buffer.from('PNG'), + }; + + const mockDefaultScreenService = { + getDefaultScreenBuffer: async () => Buffer.from('PNG'), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [ApiController], + providers: [ + { provide: DisplayService, useValue: mockDisplayService }, + { provide: DefaultScreenService, useValue: mockDefaultScreenService }, + { provide: SetupService, useValue: mockSetupService }, + { provide: LogService, useValue: mockLogService }, + { provide: ScreenRendererService, useValue: mockScreenRendererService }, + { provide: ConfigService, useValue: { get: (key: string, defaultValue?: unknown) => defaultValue } }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + })); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /api/display', () => { + it('should return 422 without HTTP_ID header', async () => { + const response = await request(app.getHttpServer()) + .get('/api/display') + .expect(422); + + expect(response.body.detail).toBe('Invalid device ID.'); + // Should NOT contain receivedHeaders (production hardening) + expect(response.body.extensions?.receivedHeaders).toBeUndefined(); + }); + + it('should return 200 with valid HTTP_ID header', async () => { + const response = await request(app.getHttpServer()) + .get('/api/display') + .set('HTTP_ID', 'test-api-key-12345678901234567890') + .expect(200); + + expect(response.body).toHaveProperty('image_url'); + expect(response.body).toHaveProperty('refresh_rate'); + }); + }); + + describe('GET /api/setup', () => { + it('should return 422 without HTTP_ID header', async () => { + const response = await request(app.getHttpServer()) + .get('/api/setup') + .expect(422); + + expect(response.body.detail).toBe('Invalid request headers.'); + expect(response.body.extensions?.receivedHeaders).toBeUndefined(); + }); + }); + + describe('POST /api/log', () => { + it('should return 422 without HTTP_ID header', async () => { + const response = await request(app.getHttpServer()) + .post('/api/log') + .send({ level: 'info', message: 'test log' }) + .expect(422); + + expect(response.body.detail).toBe('Device API key required'); + expect(response.body.extensions?.receivedHeaders).toBeUndefined(); + }); + }); +}); diff --git a/backend/src/api/api.controller.ts b/backend/src/api/api.controller.ts new file mode 100644 index 0000000..1b88289 --- /dev/null +++ b/backend/src/api/api.controller.ts @@ -0,0 +1,623 @@ +import { + Controller, + Get, + Post, + Body, + Headers, + Param, + Query, + Res, + ParseIntPipe, + HttpCode, + HttpStatus, + NotFoundException, + UnprocessableEntityException, + Logger, + StreamableFile, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiHeader, +} from '@nestjs/swagger'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; +import { Public } from '../common/decorators/public.decorator'; +import { DisplayService } from './display/display.service'; +import { DefaultScreenService } from './display/default-screen.service'; +import { SetupService } from './setup/setup.service'; +import { LogService } from './log/log.service'; +import { CreateLogDto } from './log/dto/create-log.dto'; +import { ScreenRendererService } from '../screen-designer/services/screen-renderer.service'; + +/** + * Device API Controller + * Handles all public API endpoints for device communication + * Compatible with Ruby Inker API + */ +@ApiTags('device-api') +@Controller('api') +@Public() +@SkipThrottle() +export class ApiController { + private readonly logger = new Logger(ApiController.name); + + constructor( + private readonly displayService: DisplayService, + private readonly defaultScreenService: DefaultScreenService, + private readonly setupService: SetupService, + private readonly logService: LogService, + private readonly screenRendererService: ScreenRendererService, + private readonly configService: ConfigService, + ) {} + + /** + * Sanitize headers for logging — remove sensitive values + */ + private sanitizeHeaders(headers: Record): string { + const sensitiveKeys = ['authorization', 'cookie', 'access-token', 'x-api-key']; + const sanitized = { ...headers }; + for (const key of Object.keys(sanitized)) { + if (sensitiveKeys.includes(key.toLowerCase())) { + sanitized[key] = '[REDACTED]'; + } + } + return JSON.stringify(sanitized); + } + + /** + * Extract base URL from request headers + * Prefers configured API_URL to prevent host header injection. + * Falls back to Host header for dynamic LAN/IP access. + */ + private getBaseUrlFromRequest(headers: Record): string { + // Use configured API_URL if set (prevents host header injection) + const configuredUrl = this.configService.get('api.url'); + if (configuredUrl && configuredUrl !== `http://localhost:${this.configService.get('port', 3002)}`) { + return configuredUrl; + } + + // Fallback: derive from request headers (for LAN access where IP varies) + const host = headers['host'] || headers['Host'] || 'localhost:3002'; + const protocol = headers['x-forwarded-proto'] || 'http'; + return `${protocol}://${host}`; + } + + /** + * Device Display Endpoint - GET /api/display + * Returns current screen content for device to display + * Uses HTTP_ID header for device identification (API key) + */ + @Get('display') + @HttpCode(HttpStatus.OK) + @ApiHeader({ + name: 'HTTP_ID', + description: 'Device API Key', + required: true, + }) + @ApiHeader({ + name: 'BASE64', + description: 'Request base64 encoded image (optional)', + required: false, + }) + @ApiHeader({ + name: 'battery-voltage', + description: 'Device battery voltage (e.g., "3.95")', + required: false, + }) + @ApiHeader({ + name: 'rssi', + description: 'WiFi signal strength in dBm (e.g., "-51")', + required: false, + }) + @ApiOperation({ + summary: 'Get display content for device', + description: + 'Device polling endpoint - returns current screen to display with optional firmware update info', + }) + @ApiResponse({ + status: 200, + description: 'Current screen content returned', + schema: { + example: { + status: 0, + filename: 'design-5-1702069200000.png', + image_url: 'http://localhost:3002/api/device-images/design/5?t=1702069200000', + firmware_url: '', + update_firmware: false, + refresh_rate: 900, + reset_firmware: false, + }, + }, + }) + @ApiResponse({ status: 404, description: 'Device not found' }) + async getDisplay(@Headers() headers: Record) { + // DEBUG: Log ALL incoming headers to see what the device is actually sending + this.logger.debug(`[DISPLAY] Incoming headers: ${this.sanitizeHeaders(headers)}`); + + // Try multiple header name variations (case-insensitive) + const deviceApiKey = this.extractHeader(headers, [ + 'http_id', + 'HTTP_ID', + 'Http-Id', + 'http-id', + 'id', + 'ID', + 'x-device-id', + 'device-id', + 'access-token', + 'Access-Token', + ]); + + const base64 = this.extractHeader(headers, [ + 'base64', + 'BASE64', + 'Base64', + ]); + + // Extract battery voltage header (e.g., "3.95" volts) + const batteryVoltageStr = this.extractHeader(headers, [ + 'battery-voltage', + 'Battery-Voltage', + 'battery_voltage', + 'batteryvoltage', + ]); + + // Extract RSSI (WiFi signal strength) header (e.g., "-51" dBm) + const rssiStr = this.extractHeader(headers, [ + 'rssi', + 'RSSI', + 'Rssi', + 'wifi-rssi', + 'wifi_rssi', + ]); + + // Extract firmware version header + const firmwareVersion = this.extractHeader(headers, [ + 'http_fw_version', + 'HTTP_FW_VERSION', + 'Http-Fw-Version', + 'http-fw-version', + 'fw-version', + 'firmware-version', + 'Firmware-Version', + 'version', + ]); + + // Parse battery voltage to percentage (approximate conversion) + // Typical LiPo: 4.2V = 100%, 3.0V = 0% + const batteryVoltage = batteryVoltageStr ? parseFloat(batteryVoltageStr) : undefined; + const battery = batteryVoltage !== undefined && !isNaN(batteryVoltage) + ? this.voltageToPercentage(batteryVoltage) + : undefined; + + // Parse RSSI to integer + const wifi = rssiStr ? parseInt(rssiStr, 10) : undefined; + + this.logger.debug(`[DISPLAY] Extracted deviceApiKey: ${deviceApiKey}, battery: ${battery}%, wifi: ${wifi} dBm, fw: ${firmwareVersion}`); + + if (!deviceApiKey) { + this.logger.error(`[DISPLAY] Missing HTTP_ID header. All headers: ${this.sanitizeHeaders(headers)}`); + throw new UnprocessableEntityException({ + type: '/problem_details#device_id', + status: 'unprocessable_content', + detail: 'Invalid device ID.', + instance: '/api/display', + extensions: { + errors: { HTTP_ID: ['is missing'] }, + }, + }); + } + + try { + // Get dynamic base URL from request host header + const baseUrl = this.getBaseUrlFromRequest(headers); + + const result = await this.displayService.getDisplayContent( + deviceApiKey, + base64 === 'true', + { battery, wifi }, + baseUrl, + firmwareVersion, + ); + + this.logger.debug(`Display content served to device: ${deviceApiKey.slice(0, 8)}... (baseUrl: ${baseUrl})`); + return result; + } catch (error) { + if (error instanceof NotFoundException) { + throw new NotFoundException({ + type: '/problem_details#device_id', + status: 'not_found', + detail: 'Invalid device ID.', + instance: '/api/display', + }); + } + throw error; + } + } + + /** + * Device Setup Endpoint - GET /api/setup + * Auto-provisions device using MAC address + * Uses HTTP_ID header for MAC address and optional HTTP_FW_VERSION for firmware version + */ + @Get('setup') + @HttpCode(HttpStatus.OK) + @ApiHeader({ + name: 'HTTP_ID', + description: 'Device MAC Address', + required: true, + }) + @ApiHeader({ + name: 'HTTP_FW_VERSION', + description: 'Firmware Version (optional)', + required: false, + }) + @ApiHeader({ + name: 'battery-voltage', + description: 'Device battery voltage (e.g., "3.95")', + required: false, + }) + @ApiHeader({ + name: 'rssi', + description: 'WiFi signal strength in dBm (e.g., "-51")', + required: false, + }) + @ApiOperation({ + summary: 'Auto-provision device (setup endpoint)', + description: + 'Allows device to self-register using MAC address. Returns API key and configuration.', + }) + @ApiResponse({ + status: 200, + description: 'Device provisioned successfully', + schema: { + example: { + api_key: 'S_PMVGON8htPIae-zRiL6vhGZmo3n1ftYLKvL_9J1f0', + friendly_id: 'calm-lion-39', + image_url: 'http://localhost:3001/assets/setup.bmp', + message: 'Welcome to Inker!', + }, + }, + }) + @ApiResponse({ status: 422, description: 'Invalid MAC address or setup failed' }) + @Throttle({ default: { limit: 10, ttl: 60000 } }) + async getSetup(@Headers() headers: Record) { + // DEBUG: Log ALL incoming headers to see what the device is actually sending + this.logger.debug(`[SETUP] Incoming headers: ${this.sanitizeHeaders(headers)}`); + + // Try multiple header name variations (case-insensitive) + const macAddress = this.extractHeader(headers, [ + 'http_id', + 'HTTP_ID', + 'Http-Id', + 'http-id', + 'id', + 'ID', + 'mac-address', + 'mac_address', + 'x-device-id', + ]); + + const firmwareVersion = this.extractHeader(headers, [ + 'http_fw_version', + 'HTTP_FW_VERSION', + 'Http-Fw-Version', + 'http-fw-version', + 'fw-version', + 'firmware-version', + 'version', + ]); + + // Extract battery voltage header (e.g., "3.95" volts) + const batteryVoltageStr = this.extractHeader(headers, [ + 'battery-voltage', + 'Battery-Voltage', + 'battery_voltage', + 'batteryvoltage', + ]); + + // Extract RSSI (WiFi signal strength) header (e.g., "-51" dBm) + const rssiStr = this.extractHeader(headers, [ + 'rssi', + 'RSSI', + 'Rssi', + 'wifi-rssi', + 'wifi_rssi', + ]); + + // Parse battery voltage to percentage (approximate conversion) + const batteryVoltage = batteryVoltageStr ? parseFloat(batteryVoltageStr) : undefined; + const battery = batteryVoltage !== undefined && !isNaN(batteryVoltage) + ? this.voltageToPercentage(batteryVoltage) + : undefined; + + // Parse RSSI to integer + const wifi = rssiStr ? parseInt(rssiStr, 10) : undefined; + + this.logger.debug(`[SETUP] Extracted macAddress: ${macAddress}, firmwareVersion: ${firmwareVersion}, battery: ${battery}%, wifi: ${wifi} dBm`); + + if (!macAddress) { + this.logger.error(`[SETUP] Missing HTTP_ID header. All headers: ${this.sanitizeHeaders(headers)}`); + throw new UnprocessableEntityException({ + type: '/problem_details#device_setup', + status: 'unprocessable_content', + detail: 'Invalid request headers.', + instance: '/api/setup', + extensions: { + errors: { HTTP_ID: ['is missing'] }, + }, + }); + } + + try { + // Get dynamic base URL from request host header + const baseUrl = this.getBaseUrlFromRequest(headers); + + const result = await this.setupService.provisionDevice( + macAddress, + firmwareVersion, + { battery, wifi }, + baseUrl, // Pass dynamic URL to service + ); + + this.logger.log(`Device setup: ${macAddress} (baseUrl: ${baseUrl})`); + return result; + } catch (error) { + this.logger.error(`[SETUP] Device provisioning failed: ${error.message}`); + throw new UnprocessableEntityException({ + type: '/problem_details#device_setup', + status: 'not_found', + detail: 'Device setup failed', + instance: '/api/setup', + }); + } + } + + /** + * Device Log Endpoint - POST /api/log + * Accepts log data from devices + */ + @Post('log') + @HttpCode(HttpStatus.CREATED) + @ApiHeader({ + name: 'HTTP_ID', + description: 'Device API Key', + required: true, + }) + @ApiOperation({ + summary: 'Create device log entry', + description: 'Allows devices to send log data for debugging and monitoring', + }) + @ApiResponse({ status: 201, description: 'Log entry created' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + async createLog( + @Headers() headers: Record, + @Body() createLogDto: CreateLogDto, + ) { + // DEBUG: Log ALL incoming headers + this.logger.debug(`[LOG] Incoming headers: ${this.sanitizeHeaders(headers)}`); + + const deviceApiKey = this.extractHeader(headers, [ + 'http_id', + 'HTTP_ID', + 'Http-Id', + 'http-id', + 'id', + 'ID', + 'x-device-id', + 'access-token', + 'Access-Token', + ]); + + this.logger.debug(`[LOG] Extracted deviceApiKey: ${deviceApiKey}`); + + if (!deviceApiKey) { + this.logger.error(`[LOG] Missing HTTP_ID header. All headers: ${this.sanitizeHeaders(headers)}`); + throw new UnprocessableEntityException({ + type: '/problem_details#device_log', + status: 'unprocessable_content', + detail: 'Device API key required', + instance: '/api/log', + extensions: { + errors: { HTTP_ID: ['is missing'] }, + }, + }); + } + + const result = await this.logService.createLog(deviceApiKey, createLogDto); + + this.logger.debug(`Log created for device: ${deviceApiKey.slice(0, 8)}...`); + return result; + } + + /** + * Helper method to extract header value from multiple possible header names + * NestJS/Express lowercases all headers, so we need to try multiple variations + */ + private extractHeader(headers: Record, possibleNames: string[]): string | undefined { + // First, try exact matches + for (const name of possibleNames) { + if (headers[name]) { + return headers[name]; + } + } + + // If no exact match, try case-insensitive search + const headerKeys = Object.keys(headers); + for (const name of possibleNames) { + const matchingKey = headerKeys.find( + (key) => key.toLowerCase() === name.toLowerCase() + ); + if (matchingKey && headers[matchingKey]) { + return headers[matchingKey]; + } + } + + return undefined; + } + + /** + * Convert battery voltage to percentage + * LiPo battery: 4.2V = 100%, 3.5V = 0% (device low-voltage cutoff) + * Using linear approximation for simplicity + */ + private voltageToPercentage(voltage: number): number { + const minVoltage = 3.5; // 0% battery - device low-voltage cutoff + const maxVoltage = 4.2; // 100% battery + + if (voltage >= maxVoltage) return 100; + if (voltage <= minVoltage) return 0; + + const percentage = ((voltage - minVoltage) / (maxVoltage - minVoltage)) * 100; + return Math.round(percentage); + } + + /** + * Legacy setup endpoint with trailing slash + * For compatibility with firmware 1.5.x + */ + @Get('setup/') + @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 10, ttl: 60000 } }) + async getSetupLegacy(@Headers() headers: Record) { + return this.getSetup(headers); + } + + /** + * Device Current Screen Preview Endpoint - GET /api/device-images/device/:id + * Returns the PNG image that a device is currently displaying (preview mode) + * Used by admin UI to preview what a device should be showing + */ + @Get('device-images/device/:id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Render current screen for a device (preview for admin)' }) + @ApiResponse({ + status: 200, + description: 'PNG image of the current screen', + content: { 'image/png': {} }, + }) + @ApiResponse({ status: 404, description: 'Device not found' }) + async renderDeviceCurrentScreen( + @Param('id', ParseIntPipe) id: number, + @Res() res: Response, + ) { + try { + const imageBuffer = await this.displayService.getCurrentScreenImage(id); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }); + + res.send(imageBuffer); + } catch (error) { + this.logger.error(`Failed to render current screen for device ${id}: ${error.message}`); + throw new NotFoundException('Device or screen not found'); + } + } + + /** + * Screen Design Render Endpoint - GET /api/device-images/design/:id + * Returns rendered PNG image for designed screens (public, no auth required) + * Used by devices to fetch designed screen images + * + * Query parameters: + * - t: Cache buster timestamp + * - battery: Device battery percentage (0-100) + * - wifi: Device WiFi RSSI in dBm + * - deviceName: Device name + * - firmwareVersion: Device firmware version + * - macAddress: Device MAC address + * - mode: Render mode ('device' | 'preview' | 'einkPreview') + * - device: Full e-ink processing with inversion (default, for actual device) + * - preview: No e-ink processing (RGB preview for admin UI) + * - einkPreview: Full e-ink processing without inversion (pixel-perfect preview on RGB display) + * - preview: Legacy boolean parameter (deprecated, use mode instead) + */ + @Get('device-images/design/:id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Render a screen design to PNG (public, for devices)' }) + @ApiResponse({ + status: 200, + description: 'PNG image of the rendered screen design', + content: { 'image/png': {} }, + }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async renderScreenDesignPublic( + @Param('id', ParseIntPipe) id: number, + @Query('battery') battery: string, + @Query('wifi') wifi: string, + @Query('deviceName') deviceName: string, + @Query('firmwareVersion') firmwareVersion: string, + @Query('macAddress') macAddress: string, + @Query('mode') mode: string, + @Query('preview') preview: string, + @Res() res: Response, + ) { + try { + // Determine render mode: + // 1. Use explicit mode parameter if provided + // 2. Fall back to legacy preview parameter for backwards compatibility + // 3. Default to 'device' mode + let renderMode: 'device' | 'preview' | 'einkPreview' = 'device'; + if (mode === 'preview' || mode === 'einkPreview' || mode === 'device') { + renderMode = mode; + } else if (preview === 'true' || preview === '1') { + renderMode = 'preview'; + } + + // NOTE: Capture serving is handled by display.service.ts which returns capture URLs + // for static screens and render URLs for dynamic screens (clock, countdown, weather). + // This endpoint should ALWAYS render fresh to support dynamic widgets. + + // Build device context from query params + const deviceContext = { + battery: battery ? parseFloat(battery) : undefined, + wifi: wifi ? parseInt(wifi, 10) : undefined, + deviceName: deviceName || undefined, + firmwareVersion: firmwareVersion || undefined, + macAddress: macAddress || undefined, + }; + + // Fall back to re-rendering if no capture exists + const imageBuffer = await this.screenRendererService.renderScreenDesign(id, deviceContext, renderMode); + + // Disable caching for all render modes - admin UI needs fresh previews + const cacheHeaders = { + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }; + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': imageBuffer.length, + ...cacheHeaders, + }); + + res.send(imageBuffer); + } catch (error) { + this.logger.error(`Failed to render screen design ${id}: ${error.message}`); + // Return default screen instead of 404 to prevent device loading loop + try { + const fallbackBuffer = await this.defaultScreenService.getDefaultScreenBuffer(); + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': fallbackBuffer.length, + 'Cache-Control': 'no-store', + }); + res.send(fallbackBuffer); + } catch { + throw new NotFoundException('Screen design not found'); + } + } + } +} diff --git a/backend/src/api/api.module.ts b/backend/src/api/api.module.ts new file mode 100644 index 0000000..5606aa8 --- /dev/null +++ b/backend/src/api/api.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; +import { ApiController } from './api.controller'; +import { DisplayService } from './display/display.service'; +import { DefaultScreenService } from './display/default-screen.service'; +import { SetupService } from './setup/setup.service'; +import { SetupScreenService } from './setup/setup-screen.service'; +import { LogService } from './log/log.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ScreenDesignerModule } from '../screen-designer/screen-designer.module'; + +@Module({ + imports: [ + PrismaModule, + ScreenDesignerModule, + // Serve static files from assets directory at /assets path + ServeStaticModule.forRoot({ + rootPath: join(process.cwd(), 'assets'), + serveRoot: '/assets', + serveStaticOptions: { + index: false, + fallthrough: true, + }, + }), + // Serve static files from uploads directory at /uploads path + ServeStaticModule.forRoot({ + rootPath: join(process.cwd(), 'uploads'), + serveRoot: '/uploads', + serveStaticOptions: { + index: false, + fallthrough: true, + }, + }), + ], + controllers: [ApiController], + providers: [DisplayService, DefaultScreenService, SetupService, SetupScreenService, LogService], + exports: [DisplayService, DefaultScreenService, SetupService, SetupScreenService, LogService], +}) +export class ApiModule {} diff --git a/backend/src/api/display/default-screen.service.ts b/backend/src/api/display/default-screen.service.ts new file mode 100644 index 0000000..8e596d1 --- /dev/null +++ b/backend/src/api/display/default-screen.service.ts @@ -0,0 +1,300 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as sharpModule from 'sharp'; +// Handle both ESM and CJS imports for Bun compatibility +const sharp = (sharpModule as any).default || sharpModule; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Default Screen Service + * Generates a default "Hello World" screen for devices without a playlist + * Uses Sharp for pure image generation (no Puppeteer/browser dependency) + */ +@Injectable() +export class DefaultScreenService implements OnModuleInit { + private readonly logger = new Logger(DefaultScreenService.name); + private readonly assetsDir: string; + private readonly defaultScreenPath: string; + private defaultScreenGenerated = false; + + // Default TRMNL e-ink display dimensions (800x480) + private readonly DEFAULT_WIDTH = 800; + private readonly DEFAULT_HEIGHT = 480; + + constructor(private readonly config: ConfigService) { + this.assetsDir = path.join(process.cwd(), 'assets'); + this.defaultScreenPath = path.join(this.assetsDir, 'default-screen.png'); + } + + /** + * Generate default screen on module initialization + */ + async onModuleInit() { + await this.ensureDefaultScreenExists(); + } + + /** + * Ensure the default screen image exists + * Generates it if not present + */ + async ensureDefaultScreenExists(): Promise { + try { + // Create assets directory if it doesn't exist + await fs.mkdir(this.assetsDir, { recursive: true }); + + // Check if default screen already exists + try { + await fs.access(this.defaultScreenPath); + this.logger.log(`Default screen already exists at: ${this.defaultScreenPath}`); + this.defaultScreenGenerated = true; + return; + } catch { + // File doesn't exist, generate it + } + + await this.generateDefaultScreen(); + this.defaultScreenGenerated = true; + this.logger.log(`Default screen generated at: ${this.defaultScreenPath}`); + } catch (error) { + this.logger.error('Failed to ensure default screen exists:', error); + } + } + + /** + * Generate the default screen image using Sharp + * Creates a clean, e-ink optimized image with the text: + * - "Hello World" + * - "This is Inker!" + */ + async generateDefaultScreen( + width: number = this.DEFAULT_WIDTH, + height: number = this.DEFAULT_HEIGHT, + ): Promise { + this.logger.log(`Generating default screen: ${width}x${height}`); + + try { + // Create SVG with the text content + // SVG is rendered by Sharp and produces clean text output + const svg = this.createDefaultScreenSvg(width, height); + + // Convert SVG to e-ink optimized 1-bit PNG (same pipeline as designed screens) + // TRMNL OG firmware requires dithered, negated, 1-bit palette PNG + const grayBuffer = await sharp(Buffer.from(svg)) + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + const dithered = this.applyFloydSteinbergDithering( + grayBuffer.data, grayBuffer.info.width, grayBuffer.info.height, 140, + ); + + await sharp(dithered, { + raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 }, + }) + .negate() + .png({ compressionLevel: 9, palette: true, colours: 2 }) + .toFile(this.defaultScreenPath); + + this.logger.log(`Default screen saved to: ${this.defaultScreenPath}`); + return this.defaultScreenPath; + } catch (error) { + this.logger.error('Failed to generate default screen:', error); + throw error; + } + } + + /** + * Create SVG content for the default screen + * Clean, centered design optimized for e-ink displays + */ + private createDefaultScreenSvg(width: number, height: number): string { + const centerX = width / 2; + const centerY = height / 2; + + return ` + + + + + + Hello World + + + This is Inker! + + + + + + + + `.trim(); + } + + /** + * Get the URL path for the default screen + */ + getDefaultScreenUrl(): string { + return '/assets/default-screen.png'; + } + + /** + * Get the filesystem path to the default screen + */ + getDefaultScreenPath(): string { + return this.defaultScreenPath; + } + + /** + * Check if default screen has been generated + */ + isReady(): boolean { + return this.defaultScreenGenerated; + } + + /** + * Force regeneration of the default screen + * Useful if dimensions or content need to change + */ + async regenerate( + width: number = this.DEFAULT_WIDTH, + height: number = this.DEFAULT_HEIGHT, + ): Promise { + this.logger.log('Force regenerating default screen...'); + return this.generateDefaultScreen(width, height); + } + + /** + * Generate a custom default screen for a specific device model + * @param modelWidth Device screen width + * @param modelHeight Device screen height + * @returns Path to the generated screen + */ + async generateForModel( + modelWidth: number, + modelHeight: number, + ): Promise { + const filename = `default-screen-${modelWidth}x${modelHeight}.png`; + const outputPath = path.join(this.assetsDir, filename); + + // Check if this resolution already exists + try { + await fs.access(outputPath); + return outputPath; + } catch { + // Generate new resolution + } + + this.logger.log(`Generating default screen for model: ${modelWidth}x${modelHeight}`); + + const svg = this.createDefaultScreenSvg(modelWidth, modelHeight); + + const grayBuffer = await sharp(Buffer.from(svg)) + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + const dithered = this.applyFloydSteinbergDithering( + grayBuffer.data, grayBuffer.info.width, grayBuffer.info.height, 140, + ); + + await sharp(dithered, { + raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 }, + }) + .negate() + .png({ compressionLevel: 9, palette: true, colours: 2 }) + .toFile(outputPath); + + return outputPath; + } + + /** + * Floyd-Steinberg dithering for 1-bit e-ink output + */ + private applyFloydSteinbergDithering( + data: Buffer, width: number, height: number, threshold: number, + ): Buffer { + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + const val = data[i]; + if (val > 200) pixels[i] = 255; + else if (val < 55) pixels[i] = 0; + else pixels[i] = val; + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const oldPixel = pixels[idx]; + const newPixel = oldPixel > threshold ? 255 : 0; + pixels[idx] = newPixel; + const error = oldPixel - newPixel; + if (x + 1 < width) pixels[idx + 1] += error * 7 / 16; + if (y + 1 < height) { + if (x - 1 >= 0) pixels[(y + 1) * width + (x - 1)] += error * 3 / 16; + pixels[(y + 1) * width + x] += error * 5 / 16; + if (x + 1 < width) pixels[(y + 1) * width + (x + 1)] += error * 1 / 16; + } + } + } + + const result = Buffer.alloc(data.length); + for (let i = 0; i < data.length; i++) { + result[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + return result; + } + + /** + * Get the default screen as base64 encoded string + */ + async getDefaultScreenBase64(): Promise { + await this.ensureDefaultScreenExists(); + + const buffer = await fs.readFile(this.defaultScreenPath); + return buffer.toString('base64'); + } + + /** + * Get the default screen as a buffer + */ + async getDefaultScreenBuffer(): Promise { + await this.ensureDefaultScreenExists(); + + return fs.readFile(this.defaultScreenPath); + } +} diff --git a/backend/src/api/display/display.service.test.ts b/backend/src/api/display/display.service.test.ts new file mode 100644 index 0000000..30a2882 --- /dev/null +++ b/backend/src/api/display/display.service.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { DisplayService } from './display.service'; +import { createMockPrisma } from '../../test/mocks/prisma.mock'; +import { createMock } from '../../test/mocks/helpers'; + +describe('DisplayService', () => { + let service: DisplayService; + let mockPrisma: ReturnType; + let mockConfig: any; + let mockDefaultScreenService: any; + let mockScreenRendererService: any; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + mockConfig = { get: createMock().mockReturnValue('http://localhost:3002') }; + mockDefaultScreenService = { + getDefaultScreenUrl: createMock().mockReturnValue('/uploads/default-screen.png'), + getDefaultScreenBase64: createMock().mockResolvedValue('base64data'), + getDefaultScreenBuffer: createMock().mockResolvedValue(Buffer.from('PNG')), + }; + mockScreenRendererService = { + renderScreenDesign: createMock().mockResolvedValue(Buffer.from('PNG')), + }; + service = new DisplayService( + mockPrisma as any, + mockConfig, + mockDefaultScreenService, + mockScreenRendererService, + ); + }); + + describe('getCurrentScreen (private)', () => { + const getCurrentScreen = (items: any[]) => + (service as any).getCurrentScreen(items); + + it('should return null for empty items', () => { + expect(getCurrentScreen([])).toBeNull(); + expect(getCurrentScreen(null)).toBeNull(); + }); + + it('should default duration to 60 when duration is 0 (falsy)', () => { + // duration 0 is falsy, so `item.duration || 60` defaults to 60 + const items = [{ id: 1, duration: 0 }]; + const result = getCurrentScreen(items); + expect(result.item.id).toBe(1); + expect(result.remainingTime).toBeGreaterThan(0); + expect(result.remainingTime).toBeLessThanOrEqual(60); + }); + + it('should return single item with calculated remaining time', () => { + const items = [{ id: 1, duration: 60 }]; + const result = getCurrentScreen(items); + expect(result.item.id).toBe(1); + expect(result.remainingTime).toBeGreaterThan(0); + expect(result.remainingTime).toBeLessThanOrEqual(60); + }); + + it('should default duration to 60 when not set', () => { + const items = [{ id: 1 }]; + const result = getCurrentScreen(items); + expect(result.item.id).toBe(1); + expect(result.remainingTime).toBeGreaterThan(0); + expect(result.remainingTime).toBeLessThanOrEqual(60); + }); + + it('should rotate through multiple screens based on time', () => { + const items = [ + { id: 1, duration: 30 }, + { id: 2, duration: 30 }, + ]; + const result = getCurrentScreen(items); + expect(result).not.toBeNull(); + expect([1, 2]).toContain(result.item.id); + expect(result.remainingTime).toBeGreaterThan(0); + expect(result.remainingTime).toBeLessThanOrEqual(30); + }); + }); + + describe('hasTimeSensitiveWidgets (private)', () => { + const hasTimeSensitive = (design: any) => + (service as any).hasTimeSensitiveWidgets(design); + + it('should return false for null/empty design', () => { + expect(hasTimeSensitive(null)).toBe(false); + expect(hasTimeSensitive({})).toBe(false); + expect(hasTimeSensitive({ widgets: [] })).toBe(false); + }); + + it('should return true for clock widget', () => { + expect(hasTimeSensitive({ + widgets: [{ template: { name: 'clock' } }], + })).toBe(true); + }); + + it('should return true for countdown widget', () => { + expect(hasTimeSensitive({ + widgets: [{ template: { name: 'countdown' } }], + })).toBe(true); + }); + + it('should return true for date widget', () => { + expect(hasTimeSensitive({ + widgets: [{ template: { name: 'date' } }], + })).toBe(true); + }); + + it('should return false for non-time widgets', () => { + expect(hasTimeSensitive({ + widgets: [{ template: { name: 'text' } }, { template: { name: 'weather' } }], + })).toBe(false); + }); + }); + + describe('hasClockWidget (private)', () => { + const hasClock = (design: any) => (service as any).hasClockWidget(design); + + it('should return false for no widgets', () => { + expect(hasClock(null)).toBe(false); + expect(hasClock({ widgets: [] })).toBe(false); + }); + + it('should return true when clock exists', () => { + expect(hasClock({ widgets: [{ template: { name: 'clock' } }] })).toBe(true); + }); + + it('should return false for countdown (not clock)', () => { + expect(hasClock({ widgets: [{ template: { name: 'countdown' } }] })).toBe(false); + }); + }); + + describe('getRefreshRateForScreen (private)', () => { + const getRate = (screen: any, deviceRate: number, immediate: boolean, remaining: number) => + (service as any).getRefreshRateForScreen(screen, deviceRate, immediate, remaining); + + it('should return 1 when shouldRefreshImmediately is true', () => { + expect(getRate({}, 900, true, 60)).toBe(1); + }); + + it('should return device refresh rate for normal screens', () => { + const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } }; + expect(getRate(screen, 900, false, 1000)).toBe(900); + }); + + it('should return 60 for time-sensitive (non-clock) widgets', () => { + const screen = { screenDesign: { widgets: [{ template: { name: 'countdown' } }] } }; + expect(getRate(screen, 900, false, 1000)).toBe(60); + }); + + it('should calculate clock refresh based on seconds until next minute', () => { + const screen = { screenDesign: { widgets: [{ template: { name: 'clock' } }] } }; + const rate = getRate(screen, 900, false, 1000); + // Should be between 4 (0 seconds into minute + 3 buffer) and 63 (59 seconds + 3 + cap) + expect(rate).toBeGreaterThanOrEqual(4); + expect(rate).toBeLessThanOrEqual(63); + }); + + it('should cap at remaining time when remaining is smaller', () => { + const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } }; + expect(getRate(screen, 900, false, 30)).toBe(30); + }); + }); + + describe('getNextRefreshTimestamp', () => { + it('should return ~1 second from now when immediate', () => { + const screen = {}; + const ts = service.getNextRefreshTimestamp(screen, 900, true, 60); + expect(ts).not.toBeNull(); + expect(ts! - Date.now()).toBeLessThanOrEqual(2000); + }); + + it('should return device refresh rate ms from now for normal screens', () => { + const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } }; + const ts = service.getNextRefreshTimestamp(screen, 900, false, 1000); + const diff = ts! - Date.now(); + // Should be approximately 900 seconds from now + expect(diff).toBeGreaterThan(899000); + expect(diff).toBeLessThan(901000); + }); + + it('should cap by remaining time', () => { + const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } }; + const ts = service.getNextRefreshTimestamp(screen, 900, false, 30); + const diff = ts! - Date.now(); + expect(diff).toBeLessThanOrEqual(31000); + }); + }); + + describe('getFirmwareUpdateUrl (private)', () => { + const getFirmware = (version?: string) => + (service as any).getFirmwareUpdateUrl(version); + + it('should return empty string when no current version', async () => { + expect(await getFirmware(undefined)).toBe(''); + }); + + it('should return empty string when no stable firmware exists', async () => { + mockPrisma.firmware.findFirst.mockResolvedValue(null); + expect(await getFirmware('1.0.0')).toBe(''); + }); + + it('should return empty string when versions match', async () => { + mockPrisma.firmware.findFirst.mockResolvedValue({ version: '1.0.0', downloadUrl: 'http://fw.bin' }); + expect(await getFirmware('1.0.0')).toBe(''); + }); + + it('should return download URL when version differs', async () => { + mockPrisma.firmware.findFirst.mockResolvedValue({ version: '2.0.0', downloadUrl: 'http://fw.bin' }); + expect(await getFirmware('1.0.0')).toBe('http://fw.bin'); + }); + }); + + describe('getDisplayContent', () => { + it('should return reset_firmware with all expected fields when device not found', async () => { + mockPrisma.device.findFirst.mockResolvedValue(null); + const result = await service.getDisplayContent('unknown-key'); + expect(result.reset_firmware).toBe(true); + expect(result.status).toBe(0); + expect(result.image_url).toBe(''); + expect(result.filename).toBe(''); + expect(result.firmware_url).toBe(''); + expect(result.update_firmware).toBe(false); + expect(result.refresh_rate).toBe(0); + }); + + it('should return default screen when no playlist', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', playlist: null, refreshRate: 900, refreshPending: false, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: null, wifi: null }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + expect(result.image_url).toContain('default-screen'); + expect(result.refresh_rate).toBe(900); + expect(result.status).toBe(0); + expect(result.update_firmware).toBe(false); + expect(result.reset_firmware).toBe(false); + }); + + it('should return default screen when playlist has no items', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', playlist: { items: [] }, refreshRate: 900, refreshPending: false, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: null, wifi: null }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + expect(result.image_url).toContain('default-screen'); + expect(result.status).toBe(0); + expect(result.reset_firmware).toBe(false); + }); + + it('should return refresh_rate 1 when refreshPending is true', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', playlist: null, refreshRate: 900, refreshPending: true, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 80, wifi: -51 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key', false, { battery: 80, wifi: -51 }); + expect(result.refresh_rate).toBe(1); + }); + + it('should update device metrics', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', playlist: null, refreshRate: 900, refreshPending: false, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 85, wifi: -45 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + await service.getDisplayContent('test-key', false, { battery: 85, wifi: -45 }); + + const updateCall = mockPrisma.device.update.calls[0]; + expect(updateCall[0].data.battery).toBe(85); + expect(updateCall[0].data.wifi).toBe(-45); + }); + }); + + describe('getCurrentScreenImage', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + await expect(service.getCurrentScreenImage(999)).rejects.toThrow('Device not found'); + }); + + it('should return default buffer when no playlist', async () => { + mockPrisma.device.findUnique.mockResolvedValue({ id: 1, playlist: null }); + const result = await service.getCurrentScreenImage(1); + expect(result).toBeInstanceOf(Buffer); + }); + + it('should render screen design in preview mode', async () => { + mockPrisma.device.findUnique.mockResolvedValue({ + id: 1, + name: 'Test', + battery: 80, + wifi: -51, + playlist: { + items: [{ + duration: 60, + screenDesign: { id: 5, widgets: [] }, + screen: null, + }], + }, + }); + await service.getCurrentScreenImage(1); + expect(mockScreenRendererService.renderScreenDesign.calls.length).toBe(1); + // Second arg is deviceContext, third is preview=true + expect(mockScreenRendererService.renderScreenDesign.calls[0][2]).toBe(true); + }); + }); + + describe('getImageFilename (private)', () => { + const getFilename = (url: string) => (service as any).getImageFilename(url); + + it('should extract filename from URL path', () => { + expect(getFilename('/uploads/screens/test.png')).toBe('test.png'); + }); + + it('should handle full URLs', () => { + expect(getFilename('http://localhost/uploads/test.png')).toBe('test.png'); + }); + }); +}); diff --git a/backend/src/api/display/display.service.ts b/backend/src/api/display/display.service.ts new file mode 100644 index 0000000..d25a9ea --- /dev/null +++ b/backend/src/api/display/display.service.ts @@ -0,0 +1,715 @@ +import { + Injectable, + NotFoundException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { DefaultScreenService } from './default-screen.service'; +import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Device metrics from headers + */ +export interface DeviceMetrics { + battery?: number; // Battery percentage (0-100) + wifi?: number; // WiFi RSSI in dBm (e.g., -51) +} + +@Injectable() +export class DisplayService { + private readonly logger = new Logger(DisplayService.name); + + constructor( + private prisma: PrismaService, + private config: ConfigService, + private defaultScreenService: DefaultScreenService, + private screenRendererService: ScreenRendererService, + ) {} + + /** + * Get display content for device + * Called by device using its MAC address (from id header) to fetch current screen to display + * + * @param macAddressOrApiKey - Device MAC address or API key + * @param useBase64 - Whether to include base64 encoded image + * @param metrics - Device metrics (battery, wifi) + * @param baseUrl - Dynamic base URL from request (e.g., "http://localhost:3002") + */ + async getDisplayContent( + macAddressOrApiKey: string, + useBase64: boolean = false, + metrics?: { battery?: number; wifi?: number }, + baseUrl?: string, + firmwareVersion?: string, + ) { + // Use dynamic baseUrl from request, or fall back to config + const apiUrl = baseUrl || this.config.get('api.url', 'http://localhost:3002'); + // Find device by MAC address (id header) or API key (access-token header) + // The Ruby version looks up by MAC address for better compatibility + const device = await this.prisma.device.findFirst({ + where: { + OR: [ + { macAddress: macAddressOrApiKey }, + { apiKey: macAddressOrApiKey }, + ], + }, + include: { + model: true, + playlist: { + include: { + items: { + include: { + screen: true, + screenDesign: { + include: { + widgets: { + include: { + template: true, + }, + }, + }, + }, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }, + }, + }); + + if (!device) { + // Return reset signal instead of 404 - tells device to factory reset + // Device will clear its API key and return to setup mode + // Include ALL expected fields so ArduinoJson on ESP32 doesn't fail parsing + this.logger.log(`Device not found for key ${macAddressOrApiKey} - sending factory reset signal`); + return { + status: 0, + image_url: '', + filename: '', + firmware_url: '', + update_firmware: false, + refresh_rate: 0, + reset_firmware: true, + message: 'Device removed from server', + }; + } + + // Check if device has a pending refresh (playlist just changed) + const shouldRefreshImmediately = device.refreshPending; + + // Build update data with lastSeenAt and optional metrics + const updateData: { + lastSeenAt: Date; + battery?: number; + wifi?: number; + firmwareVersion?: string; + refreshPending?: boolean; + } = { + lastSeenAt: new Date(), + // Reset refreshPending flag after serving content + refreshPending: false, + }; + + // Update battery if provided (store as percentage) + if (metrics?.battery !== undefined && !isNaN(metrics.battery)) { + updateData.battery = metrics.battery; + } + + // Update wifi RSSI if provided + if (metrics?.wifi !== undefined && !isNaN(metrics.wifi)) { + updateData.wifi = metrics.wifi; + } + + // Update firmware version if provided and changed + if (firmwareVersion && firmwareVersion !== device.firmwareVersion) { + updateData.firmwareVersion = firmwareVersion; + } + + // Update device with last seen timestamp and metrics + const updatedDevice = await this.prisma.device.update({ + where: { id: device.id }, + data: updateData, + }); + + if (shouldRefreshImmediately) { + this.logger.log(`Device ${device.name} has pending refresh - sending immediate refresh signal`); + } + + this.logger.debug( + `Device ${device.name} updated: battery=${updatedDevice.battery}%, wifi=${updatedDevice.wifi} dBm`, + ); + + // Check for firmware update + const firmwareUrl = await this.getFirmwareUpdateUrl(device.firmwareVersion || undefined); + + // Default refresh rate (used for default screens or when no playlist) + const defaultRefreshRate = shouldRefreshImmediately ? 1 : device.refreshRate; + + // If no playlist or no screens in playlist, return the default "Hello World" screen + if (!device.playlist || !device.playlist.items || device.playlist.items.length === 0) { + this.logger.log(`Device ${device.name} has no playlist - serving default screen`); + + const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl(); + const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`; + + // Get base64 if requested + let imageData: string | undefined; + if (useBase64) { + try { + imageData = await this.defaultScreenService.getDefaultScreenBase64(); + } catch (error) { + this.logger.warn('Failed to get default screen base64:', error); + } + } + + return { + status: 0, + image_url: fullDefaultUrl, + filename: 'default-screen.png', + image_data: imageData, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: defaultRefreshRate, + reset_firmware: false, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } + + // Get current screen from playlist rotation + const currentScreenResult = this.getCurrentScreen(device.playlist.items); + + if (!currentScreenResult) { + this.logger.log(`Device ${device.name} playlist has no valid screens - serving default screen`); + + const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl(); + const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`; + + // Get base64 if requested + let imageData: string | undefined; + if (useBase64) { + try { + imageData = await this.defaultScreenService.getDefaultScreenBase64(); + } catch (error) { + this.logger.warn('Failed to get default screen base64:', error); + } + } + + return { + status: 0, + image_url: fullDefaultUrl, + filename: 'default-screen.png', + image_data: imageData, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: defaultRefreshRate, + reset_firmware: false, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } + + const { item: currentScreen, remainingTime } = currentScreenResult; + + // Generate unique screen ID to detect screen changes + const currentScreenId = currentScreen.screenDesign + ? `design-${currentScreen.screenDesign.id}` + : currentScreen.screen + ? `screen-${currentScreen.screen.id}` + : null; + + // Detect screen change for ghosting prevention (full refresh on e-ink) + const screenChanged = currentScreenId && device.lastScreenId !== currentScreenId; + if (screenChanged) { + this.logger.debug( + `Screen changed for device ${device.name}: ${device.lastScreenId} -> ${currentScreenId} (will trigger full refresh)`, + ); + // Update lastScreenId in database + await this.prisma.device.update({ + where: { id: device.id }, + data: { lastScreenId: currentScreenId }, + }); + } + + // Calculate refresh rate based on current screen content and remaining playlist time + // Time-sensitive widgets (clock, countdown, date) get 60s refresh instead of device default + // But never exceed the remaining time for the current screen in the playlist + const effectiveRefreshRate = this.getRefreshRateForScreen( + currentScreen, + device.refreshRate, + shouldRefreshImmediately, + remainingTime, + ); + + // Calculate the next refresh timestamp for minute-synchronized clock updates + const nextRefreshAt = this.getNextRefreshTimestamp( + currentScreen, + device.refreshRate, + shouldRefreshImmediately, + remainingTime, + ); + + // Handle both regular screens and designed screens + if (currentScreen.screen) { + // Regular uploaded screen + const imageUrl = currentScreen.screen.imageUrl.startsWith('http') + ? currentScreen.screen.imageUrl + : `${apiUrl}${currentScreen.screen.imageUrl}`; + + this.logger.debug( + `Serving screen "${currentScreen.screen.name}" to device ${device.name}`, + ); + + return { + status: 0, + image_url: imageUrl, + filename: this.getImageFilename(currentScreen.screen.imageUrl), + image_data: useBase64 ? await this.getBase64Image(currentScreen.screen.imageUrl) : undefined, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: effectiveRefreshRate, + reset_firmware: false, + refresh_at: nextRefreshAt, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } else if (currentScreen.screenDesign) { + // Designed screen - check for pre-captured pixel-perfect image first + const captureFilename = `capture_${currentScreen.screenDesign.id}.png`; + const capturePath = path.join(process.cwd(), 'uploads', 'captures', captureFilename); + const captureExists = fs.existsSync(capturePath); + + const timestamp = Date.now(); + + // Check if screen has dynamic widgets that need fresh rendering + // Dynamic widgets: clock, date, countdown, weather (change over time) + const dynamicTemplateNames = ['clock', 'date', 'countdown', 'weather']; + const hasDynamicWidgets = currentScreen.screenDesign.widgets?.some( + (widget: { template?: { name?: string } }) => + widget.template?.name && dynamicTemplateNames.includes(widget.template.name) + ); + + if (captureExists && !hasDynamicWidgets) { + // USE CAPTURE FILE - exact pixels from designer (pixel-perfect) + // Only for static screens without dynamic widgets + const captureUrl = `${apiUrl}/uploads/captures/${captureFilename}?t=${timestamp}`; + const dynamicFilename = `capture-${currentScreen.screenDesign.id}-${timestamp}.png`; + + this.logger.debug( + `Serving CAPTURED screen "${currentScreen.screenDesign.name}" to device ${device.name} (pixel-perfect, refresh: ${effectiveRefreshRate}s)`, + ); + + return { + status: 0, + image_url: captureUrl, + filename: dynamicFilename, + image_data: undefined, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: effectiveRefreshRate, + reset_firmware: false, + refresh_at: nextRefreshAt, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } + + // RENDER FRESH: No capture, has dynamic widgets, or needs current time + // Dynamic widgets (clock, countdown, weather) must be rendered fresh each time + if (hasDynamicWidgets) { + this.logger.debug( + `Screen "${currentScreen.screenDesign.name}" has dynamic widgets - rendering fresh`, + ); + } + + const queryParams = new URLSearchParams({ + t: timestamp.toString(), + battery: (updatedDevice.battery ?? 0).toString(), + wifi: (updatedDevice.wifi ?? 0).toString(), + deviceName: device.name || 'Unknown', + firmwareVersion: device.firmwareVersion || 'Unknown', + macAddress: device.macAddress ? `XX:XX:XX:${device.macAddress.slice(-8)}` : 'Unknown', + }); + const renderUrl = `${apiUrl}/api/device-images/design/${currentScreen.screenDesign.id}?${queryParams.toString()}`; + + // CRITICAL: Include timestamp in filename to force device to fetch new image + // The TRMNL device firmware caches images by filename, so if we always return + // "design-5.png", the device thinks it already has this image and won't fetch + // the new URL. By changing the filename on each request (e.g., "design-5-1702069200000.png"), + // the device recognizes it as a new file and downloads the fresh image. + const dynamicFilename = `design-${currentScreen.screenDesign.id}-${timestamp}.png`; + + this.logger.debug( + `Serving RENDERED screen "${currentScreen.screenDesign.name}" to device ${device.name} (no capture, refresh: ${effectiveRefreshRate}s, next_at: ${nextRefreshAt ? new Date(nextRefreshAt).toISOString() : 'N/A'})`, + ); + + return { + status: 0, + image_url: renderUrl, + filename: dynamicFilename, + image_data: undefined, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: effectiveRefreshRate, + reset_firmware: false, + refresh_at: nextRefreshAt, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } else { + // Neither screen nor screenDesign - should not happen, but handle gracefully + this.logger.warn(`Playlist item ${currentScreen.id} has no screen or screenDesign`); + + const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl(); + const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`; + + return { + status: 0, + image_url: fullDefaultUrl, + filename: 'default-screen.png', + image_data: undefined, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: defaultRefreshRate, + reset_firmware: false, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } + } + + /** + * Get current screen from playlist items using simple rotation + * + * Returns: { item, remainingTime } - the current playlist item and seconds until it should rotate + */ + private getCurrentScreen(items: any[]): { item: any; remainingTime: number } | null { + if (!items || items.length === 0) { + return null; + } + + // SINGLE SCREEN: Respect duration for refresh timing + if (items.length === 1) { + const itemDuration = items[0].duration || 60; + + // Duration 0 = never refresh (static content, save battery) + if (itemDuration === 0) { + return { item: items[0], remainingTime: Infinity }; + } + + // Calculate remaining time in current cycle based on duration + const currentSecond = Math.floor(Date.now() / 1000); + const remainingTime = itemDuration - (currentSecond % itemDuration); + return { item: items[0], remainingTime }; + } + + // Multiple screens: rotation based on current time + // Each screen shows for its duration, then rotates to next + const totalDuration = items.reduce((sum, item) => sum + (item.duration || 60), 0); + + // Guard against division by zero (shouldn't happen, but defensive) + if (totalDuration <= 0) { + const firstItemDuration = items[0].duration || 60; + return { item: items[0], remainingTime: firstItemDuration }; + } + + const currentSecond = Math.floor(Date.now() / 1000); + const positionInCycle = currentSecond % totalDuration; + + let elapsed = 0; + for (const item of items) { + const itemDuration = item.duration || 60; + elapsed += itemDuration; + if (positionInCycle < elapsed) { + // Calculate remaining time for this screen + const remainingTime = elapsed - positionInCycle; + return { item, remainingTime }; + } + } + + // Fallback to first item + const firstItemDuration = items[0].duration || 60; + return { item: items[0], remainingTime: firstItemDuration }; + } + + /** + * Get current time in HH:MM format + */ + private getCurrentTimeHHMM(): string { + const now = new Date(); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + } + + /** + * Check if firmware update is available + */ + private async getFirmwareUpdateUrl(currentVersion?: string): Promise { + if (!currentVersion) { + return ''; + } + + // Get latest stable firmware + const latestFirmware = await this.prisma.firmware.findFirst({ + where: { isStable: true }, + orderBy: { createdAt: 'desc' }, + }); + + if (!latestFirmware) { + return ''; + } + + // Only return update URL if version is different + if (latestFirmware.version === currentVersion) { + return ''; + } + + return latestFirmware.downloadUrl; + } + + /** + * Extract filename from image URL + */ + private getImageFilename(imageUrl: string): string { + const parts = imageUrl.split('/'); + return parts[parts.length - 1]; + } + + /** + * Get base64 encoded image (if requested by device) + * This would require actual image processing in production + */ + private async getBase64Image(imageUrl: string): Promise { + // TODO: Implement base64 encoding of image + // For now, return undefined and device will fetch via URL + return undefined; + } + + /** + * Check if a screen design contains time-sensitive widgets (clock, countdown, date) + * These widgets require more frequent refresh to stay accurate + */ + private hasTimeSensitiveWidgets(screenDesign: any): boolean { + if (!screenDesign?.widgets || !Array.isArray(screenDesign.widgets)) { + return false; + } + + // Widget template names that are time-sensitive and need frequent refresh + const timeSensitiveWidgets = ['clock', 'countdown', 'date']; + + return screenDesign.widgets.some( + (widget: any) => + widget.template && + timeSensitiveWidgets.includes(widget.template.name), + ); + } + + /** + * Check if a screen design contains a clock widget + */ + private hasClockWidget(screenDesign: any): boolean { + if (!screenDesign?.widgets || !Array.isArray(screenDesign.widgets)) { + return false; + } + + return screenDesign.widgets.some( + (widget: any) => widget.template && widget.template.name === 'clock', + ); + } + + /** + * Get the appropriate refresh rate based on screen content and playlist rotation + * For clock widgets, returns the exact seconds until next minute boundary + * This ensures the device wakes up exactly when the minute changes + * BUT never exceeds the remaining time for the current screen in the playlist + */ + private getRefreshRateForScreen( + currentScreen: any, + deviceRefreshRate: number, + shouldRefreshImmediately: boolean, + remainingTime: number, + ): number { + // If refresh is pending, return 1 second to force immediate update + if (shouldRefreshImmediately) { + return 1; + } + + let refreshRate = deviceRefreshRate; + + // For screens with clock widgets, calculate exact seconds until next minute boundary + // The device should wake up AFTER the minute changes so the clock shows the new time + if (currentScreen?.screenDesign && this.hasClockWidget(currentScreen.screenDesign)) { + const now = new Date(); + const secondsIntoMinute = now.getSeconds(); + const secondsUntilNextMinute = 60 - secondsIntoMinute; + + // Add a small buffer (3 seconds) to ensure we're past the minute boundary + // This way the clock renders the new minute, not the old one + const bufferSeconds = 3; + refreshRate = secondsUntilNextMinute + bufferSeconds; + + // Cap at reasonable values + if (refreshRate > 63) { + refreshRate = 63; + } + + this.logger.debug( + `Clock widget - calculated refresh ${refreshRate}s (${secondsUntilNextMinute}s until minute + ${bufferSeconds}s buffer)`, + ); + } else if (currentScreen?.screenDesign && this.hasTimeSensitiveWidgets(currentScreen.screenDesign)) { + // For other time-sensitive widgets (date, countdown), use 60 second refresh + refreshRate = 60; + this.logger.debug( + `Screen design "${currentScreen.screenDesign.name}" has time-sensitive widgets - using 60s refresh`, + ); + } + + // IMPORTANT: Cap refresh rate by remaining time in playlist rotation + // This ensures screens rotate according to their duration in the playlist + if (remainingTime > 0 && remainingTime < refreshRate) { + this.logger.debug( + `Capping refresh rate from ${refreshRate}s to ${remainingTime}s (playlist rotation)`, + ); + refreshRate = remainingTime; + } + + return refreshRate; + } + + /** + * Calculate the exact timestamp when the device should refresh next + * For clock widgets, this is synchronized to the next minute boundary + * This ensures the clock updates exactly when the minute changes (e.g., 20:00 -> 20:01) + * BUT never exceeds the remaining time for the current screen in the playlist + */ + getNextRefreshTimestamp( + currentScreen: any, + deviceRefreshRate: number, + shouldRefreshImmediately: boolean, + remainingTime: number, + ): number | null { + // If refresh is pending, refresh immediately + if (shouldRefreshImmediately) { + return Date.now() + 1000; // 1 second from now + } + + let refreshMs = deviceRefreshRate * 1000; + + // For screens with clock widgets, synchronize to minute boundaries + // Wake up AFTER the minute changes so the clock shows the correct new time + if (currentScreen?.screenDesign && this.hasClockWidget(currentScreen.screenDesign)) { + const now = new Date(); + // Calculate milliseconds until the next minute starts + const secondsUntilNextMinute = 60 - now.getSeconds(); + const msUntilNextMinute = (secondsUntilNextMinute * 1000) - now.getMilliseconds(); + + // Add a 3 second buffer to ensure we're past the minute boundary + // This ensures the clock renders the new minute, not the old one + const bufferMs = 3000; + refreshMs = msUntilNextMinute + bufferMs; + + this.logger.debug( + `Clock widget detected - calculated refresh in ${Math.round(refreshMs / 1000)}s (after minute boundary)`, + ); + } else if (currentScreen?.screenDesign && this.hasTimeSensitiveWidgets(currentScreen.screenDesign)) { + // For other time-sensitive widgets, use 60 second intervals + refreshMs = 60 * 1000; + } + + // IMPORTANT: Cap by remaining time in playlist rotation + const remainingTimeMs = remainingTime * 1000; + if (remainingTimeMs > 0 && remainingTimeMs < refreshMs) { + this.logger.debug( + `Capping next refresh timestamp from ${Math.round(refreshMs / 1000)}s to ${remainingTime}s (playlist rotation)`, + ); + refreshMs = remainingTimeMs; + } + + return Date.now() + refreshMs; + } + + /** + * Get the current screen image for a device (preview mode for admin UI) + * Returns the rendered PNG buffer of what the device should currently be displaying + */ + async getCurrentScreenImage(deviceId: number): Promise { + // Find device with playlist and screens + const device = await this.prisma.device.findUnique({ + where: { id: deviceId }, + include: { + playlist: { + include: { + items: { + include: { + screen: true, + screenDesign: { + include: { + widgets: { + include: { + template: true, + }, + }, + }, + }, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // If no playlist or no items, return default screen + if (!device.playlist || !device.playlist.items || device.playlist.items.length === 0) { + return this.defaultScreenService.getDefaultScreenBuffer(); + } + + // Get current screen from playlist rotation + const currentScreenResult = this.getCurrentScreen(device.playlist.items); + + if (!currentScreenResult) { + return this.defaultScreenService.getDefaultScreenBuffer(); + } + + const { item: currentScreen } = currentScreenResult; + + // Handle screen design (rendered screens) + if (currentScreen.screenDesign) { + const deviceContext = { + battery: device.battery ?? undefined, + wifi: device.wifi ?? undefined, + deviceName: device.name || undefined, + firmwareVersion: device.firmwareVersion || undefined, + macAddress: device.macAddress || undefined, + }; + + // Render in preview mode (no e-ink processing) + return this.screenRendererService.renderScreenDesign( + currentScreen.screenDesign.id, + deviceContext, + true, // preview mode + ); + } + + // Handle regular uploaded screens + if (currentScreen.screen?.imageUrl) { + // For regular screens, we'd need to read the file + // For now, return default screen as fallback + return this.defaultScreenService.getDefaultScreenBuffer(); + } + + return this.defaultScreenService.getDefaultScreenBuffer(); + } +} diff --git a/backend/src/api/log/dto/create-log.dto.ts b/backend/src/api/log/dto/create-log.dto.ts new file mode 100644 index 0000000..2bed505 --- /dev/null +++ b/backend/src/api/log/dto/create-log.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsObject, + IsArray, + ValidateIf, + MaxLength, +} from 'class-validator'; + +/** + * Create log DTO - supports both single and batch formats + * + * Single format: { level: "info", message: "..." } + * Batch format: { logs: [{ level: "info", message: "..." }, ...] } + * + * TRMNL firmware sends extra fields in batch log entries (created_at, id, + * source_line, battery_voltage, etc.) and numeric log levels. + * The logs array is typed as any[] to accept these without validation errors. + * The service extracts only the fields it needs (level, message, metadata). + */ +export class CreateLogDto { + // Single log format (used when logs array is not present) + @ApiProperty({ example: 'info', description: 'Log level', required: false }) + @ValidateIf((o) => !o.logs) + @IsString() + @MaxLength(20) + level?: string; + + @ApiProperty({ + example: 'Device started successfully', + description: 'Log message', + required: false, + }) + @ValidateIf((o) => !o.logs) + @IsString() + @MaxLength(5000) + message?: string; + + @ApiProperty({ + example: { battery: 85.5, wifi: 75 }, + required: false, + description: 'Additional metadata', + }) + @IsOptional() + @IsObject() + metadata?: Record; + + // Batch format from TRMNL firmware + // Typed as any[] to accept firmware-specific extra fields without rejection + @ApiProperty({ + required: false, + description: 'Array of log entries (batch format)', + }) + @IsOptional() + @IsArray() + logs?: any[]; +} diff --git a/backend/src/api/log/log.service.test.ts b/backend/src/api/log/log.service.test.ts new file mode 100644 index 0000000..bfecc47 --- /dev/null +++ b/backend/src/api/log/log.service.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException } from '@nestjs/common'; +import { LogService } from './log.service'; +import { createMockPrisma, MockPrisma } from '../../test/mocks/prisma.mock'; + +describe('LogService', () => { + let service: LogService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new LogService(mockPrisma as any); + }); + + // ─── createLog ─────────────────────────────────────────────────────── + + describe('createLog()', () => { + it('should throw NotFoundException when device is not found', async () => { + mockPrisma.device.findFirst.mockResolvedValue(null); + await expect( + service.createLog('bad-key', { level: 'info', message: 'test' } as any), + ).rejects.toThrow(NotFoundException); + }); + + it('should create a single log entry', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ id: 1, name: 'Device1' }); + mockPrisma.deviceLog.create.mockResolvedValue({ id: 1, level: 'info', message: 'hello' }); + + const result = await service.createLog('valid-key', { + level: 'info', + message: 'hello', + } as any); + + expect(result.status).toBe('ok'); + expect(result.message).toBe('Log created successfully'); + expect(mockPrisma.deviceLog.create.calls).toHaveLength(1); + }); + + it('should create multiple logs from batch format', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ id: 1, name: 'Device1' }); + mockPrisma.deviceLog.create.mockResolvedValue({ id: 1 }); + + const result = await service.createLog('valid-key', { + logs: [ + { level: 'info', message: 'msg1' }, + { level: 'warn', message: 'msg2' }, + { level: 'error', message: 'msg3' }, + ], + } as any); + + expect(result.status).toBe('ok'); + expect(result.count).toBe(3); + expect(mockPrisma.deviceLog.create.calls).toHaveLength(3); + }); + + it('should update device battery and wifi when metadata is provided', async () => { + mockPrisma.device.findFirst.mockResolvedValue({ id: 5, name: 'Device5' }); + mockPrisma.deviceLog.create.mockResolvedValue({ id: 1, level: 'info', message: '' }); + mockPrisma.device.update.mockResolvedValue({}); + + await service.createLog('valid-key', { + level: 'info', + message: 'status', + metadata: { battery: 85, wifi: -42 }, + } as any); + + expect(mockPrisma.device.update.calls).toHaveLength(1); + const updateCall = mockPrisma.device.update.calls[0][0]; + expect(updateCall.data.battery).toBe(85); + expect(updateCall.data.wifi).toBe(-42); + expect(updateCall.data.lastSeenAt).toBeInstanceOf(Date); + }); + }); +}); diff --git a/backend/src/api/log/log.service.ts b/backend/src/api/log/log.service.ts new file mode 100644 index 0000000..7168205 --- /dev/null +++ b/backend/src/api/log/log.service.ts @@ -0,0 +1,104 @@ +import { + Injectable, + NotFoundException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateLogDto } from './dto/create-log.dto'; + +@Injectable() +export class LogService { + private readonly logger = new Logger(LogService.name); + + constructor(private prisma: PrismaService) {} + + /** + * Create device log entry + * Called by device to send log data + * Supports both single log format and batch format + */ + async createLog(identifier: string, createLogDto: CreateLogDto) { + // Find device by MAC address or API key (firmware sends MAC via ID header) + const device = await this.prisma.device.findFirst({ + where: { + OR: [ + { macAddress: identifier }, + { apiKey: identifier }, + ], + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Handle batch format from TRMNL firmware + if (createLogDto.logs && Array.isArray(createLogDto.logs)) { + const entries = createLogDto.logs.slice(0, 100); // Cap at 100 entries to prevent DoS + const createdLogs = await Promise.all( + entries.map((logEntry) => + this.prisma.deviceLog.create({ + data: { + deviceId: device.id, + level: String(logEntry.level ?? 'info'), + message: logEntry.message || '', + metadata: logEntry.metadata || {}, + }, + }), + ), + ); + + this.logger.debug( + `Created ${createdLogs.length} batch logs for device ${device.name}`, + ); + + return { + status: 'ok', + message: `${createdLogs.length} logs created successfully`, + count: createdLogs.length, + }; + } + + // Handle single log format + const log = await this.prisma.deviceLog.create({ + data: { + deviceId: device.id, + level: createLogDto.level || 'info', + message: createLogDto.message || '', + metadata: createLogDto.metadata || {}, + }, + }); + + this.logger.debug( + `Log created for device ${device.name}: [${log.level}] ${log.message}`, + ); + + // Update device metadata if provided + if (createLogDto.metadata) { + const updates: any = {}; + + if (createLogDto.metadata.battery !== undefined) { + updates.battery = createLogDto.metadata.battery; + } + + if (createLogDto.metadata.wifi !== undefined) { + updates.wifi = createLogDto.metadata.wifi; + } + + if (Object.keys(updates).length > 0) { + await this.prisma.device.update({ + where: { id: device.id }, + data: { + ...updates, + lastSeenAt: new Date(), + }, + }); + } + } + + return { + status: 'ok', + message: 'Log created successfully', + }; + } +} diff --git a/backend/src/api/setup/setup-screen.service.ts b/backend/src/api/setup/setup-screen.service.ts new file mode 100644 index 0000000..d91eb8d --- /dev/null +++ b/backend/src/api/setup/setup-screen.service.ts @@ -0,0 +1,272 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import * as sharpModule from 'sharp'; +// Handle both ESM and CJS imports for Bun compatibility +const sharp = (sharpModule as any).default || sharpModule; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Setup Screen Service + * Generates a welcome/setup screen for newly connected devices + * This screen is shown immediately after device setup before they get their first playlist content + */ +@Injectable() +export class SetupScreenService implements OnModuleInit { + private readonly logger = new Logger(SetupScreenService.name); + private readonly assetsDir: string; + private readonly setupScreenPath: string; + private setupScreenGenerated = false; + + // Default TRMNL e-ink display dimensions (800x480) + private readonly DEFAULT_WIDTH = 800; + private readonly DEFAULT_HEIGHT = 480; + + constructor() { + this.assetsDir = path.join(process.cwd(), 'assets'); + this.setupScreenPath = path.join(this.assetsDir, 'setup.png'); + } + + /** + * Generate setup screen on module initialization + */ + async onModuleInit() { + await this.ensureSetupScreenExists(); + } + + /** + * Ensure the setup screen image exists + * Generates it if not present + */ + async ensureSetupScreenExists(): Promise { + try { + // Create assets directory if it doesn't exist + await fs.mkdir(this.assetsDir, { recursive: true }); + + // Check if setup screen already exists + try { + await fs.access(this.setupScreenPath); + this.logger.log(`Setup screen already exists at: ${this.setupScreenPath}`); + this.setupScreenGenerated = true; + return; + } catch { + // File doesn't exist, generate it + } + + await this.generateSetupScreen(); + this.setupScreenGenerated = true; + this.logger.log(`Setup screen generated at: ${this.setupScreenPath}`); + } catch (error) { + this.logger.error('Failed to ensure setup screen exists:', error); + } + } + + /** + * Generate the setup screen image using Sharp + * Creates a clean, e-ink optimized welcome screen + */ + async generateSetupScreen( + width: number = this.DEFAULT_WIDTH, + height: number = this.DEFAULT_HEIGHT, + ): Promise { + this.logger.log(`Generating setup screen: ${width}x${height}`); + + try { + // Create SVG with the welcome content + const svg = this.createSetupScreenSvg(width, height); + + // Convert SVG to e-ink optimized 1-bit PNG (same pipeline as designed screens) + const grayBuffer = await sharp(Buffer.from(svg)) + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + const dithered = this.applyFloydSteinbergDithering( + grayBuffer.data, grayBuffer.info.width, grayBuffer.info.height, 140, + ); + + await sharp(dithered, { + raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 }, + }) + .negate() + .png({ compressionLevel: 9, palette: true, colours: 2 }) + .toFile(this.setupScreenPath); + + this.logger.log(`Setup screen saved to: ${this.setupScreenPath}`); + return this.setupScreenPath; + } catch (error) { + this.logger.error('Failed to generate setup screen:', error); + throw error; + } + } + + /** + * Create SVG content for the setup screen + * Shows welcome message and setup instructions + */ + private createSetupScreenSvg(width: number, height: number): string { + const centerX = width / 2; + const centerY = height / 2; + + return ` + + + + + + + + + + + + Device Connected + + + Setup complete! Waiting for content... + + + Assign a playlist in the Inker dashboard + + + + + + Powered by Inker + + `.trim(); + } + + /** + * Get the URL path for the setup screen + */ + getSetupScreenUrl(): string { + return '/assets/setup.png'; + } + + /** + * Get the filesystem path to the setup screen + */ + getSetupScreenPath(): string { + return this.setupScreenPath; + } + + /** + * Check if setup screen has been generated + */ + isReady(): boolean { + return this.setupScreenGenerated; + } + + /** + * Floyd-Steinberg dithering for 1-bit e-ink output + */ + private applyFloydSteinbergDithering( + data: Buffer, width: number, height: number, threshold: number, + ): Buffer { + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + const val = data[i]; + if (val > 200) pixels[i] = 255; + else if (val < 55) pixels[i] = 0; + else pixels[i] = val; + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const oldPixel = pixels[idx]; + const newPixel = oldPixel > threshold ? 255 : 0; + pixels[idx] = newPixel; + const error = oldPixel - newPixel; + if (x + 1 < width) pixels[idx + 1] += error * 7 / 16; + if (y + 1 < height) { + if (x - 1 >= 0) pixels[(y + 1) * width + (x - 1)] += error * 3 / 16; + pixels[(y + 1) * width + x] += error * 5 / 16; + if (x + 1 < width) pixels[(y + 1) * width + (x + 1)] += error * 1 / 16; + } + } + } + + const result = Buffer.alloc(data.length); + for (let i = 0; i < data.length; i++) { + result[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + return result; + } + + /** + * Force regeneration of the setup screen + */ + async regenerate( + width: number = this.DEFAULT_WIDTH, + height: number = this.DEFAULT_HEIGHT, + ): Promise { + this.logger.log('Force regenerating setup screen...'); + return this.generateSetupScreen(width, height); + } + + /** + * Get the setup screen as base64 encoded string + */ + async getSetupScreenBase64(): Promise { + await this.ensureSetupScreenExists(); + + const buffer = await fs.readFile(this.setupScreenPath); + return buffer.toString('base64'); + } +} diff --git a/backend/src/api/setup/setup.service.test.ts b/backend/src/api/setup/setup.service.test.ts new file mode 100644 index 0000000..b8cf358 --- /dev/null +++ b/backend/src/api/setup/setup.service.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { BadRequestException } from '@nestjs/common'; +import { SetupService } from './setup.service'; +import { createMockPrisma, MockPrisma } from '../../test/mocks/prisma.mock'; +import { createMock } from '../../test/mocks/helpers'; + +describe('SetupService', () => { + let service: SetupService; + let mockPrisma: MockPrisma; + let mockSetupScreenService: { getSetupScreenUrl: ReturnType }; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + mockSetupScreenService = { + getSetupScreenUrl: createMock().mockReturnValue('/uploads/setup.png'), + }; + service = new SetupService(mockPrisma as any, mockSetupScreenService as any); + }); + + // ─── isValidMacAddress (private) ──────────────────────────────────── + + describe('isValidMacAddress()', () => { + const isValid = (mac: string) => (service as any).isValidMacAddress(mac); + + it('should accept colon-separated format (AA:BB:CC:DD:EE:FF)', () => { + expect(isValid('AA:BB:CC:DD:EE:FF')).toBe(true); + }); + + it('should accept hyphen-separated format (AA-BB-CC-DD-EE-FF)', () => { + expect(isValid('AA-BB-CC-DD-EE-FF')).toBe(true); + }); + + it('should accept compact format (AABBCCDDEEFF)', () => { + expect(isValid('AABBCCDDEEFF')).toBe(true); + }); + + it('should accept lowercase hex characters', () => { + expect(isValid('aa:bb:cc:dd:ee:ff')).toBe(true); + }); + + it('should reject MAC with wrong length', () => { + expect(isValid('AA:BB:CC:DD:EE')).toBe(false); + }); + + it('should reject MAC with invalid characters', () => { + expect(isValid('GG:HH:II:JJ:KK:LL')).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValid('')).toBe(false); + }); + + it('should reject random string', () => { + expect(isValid('not-a-mac')).toBe(false); + }); + }); + + // ─── generateFriendlyId (private) ────────────────────────────────── + + describe('generateFriendlyId()', () => { + it('should return adjective-noun-number format', () => { + const id = (service as any).generateFriendlyId(); + const parts = id.split('-'); + expect(parts).toHaveLength(3); + expect(parts[0]).toBeString(); + expect(parts[1]).toBeString(); + expect(Number(parts[2])).toBeFinite(); + }); + + it('should return a string containing only lowercase letters and digits', () => { + const id = (service as any).generateFriendlyId(); + expect(id).toMatch(/^[a-z]+-[a-z]+-\d+$/); + }); + }); + + // ─── provisionDevice ─────────────────────────────────────────────── + + describe('provisionDevice()', () => { + it('should throw BadRequestException for invalid MAC address', async () => { + await expect(service.provisionDevice('invalid-mac')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should return reset_firmware and delete blocked device', async () => { + mockPrisma.blockedDevice.findUnique.mockResolvedValue({ + id: 1, + macAddress: 'AA:BB:CC:DD:EE:FF', + }); + mockPrisma.blockedDevice.delete.mockResolvedValue({}); + + const result = await service.provisionDevice('AA:BB:CC:DD:EE:FF'); + + expect(result).toEqual({ + status: 200, + api_key: '', + friendly_id: '', + image_url: '', + message: 'Device was removed from server', + reset_firmware: true, + }); + expect(mockPrisma.blockedDevice.delete.calls).toHaveLength(1); + }); + + it('should return existing API key and update firmware for existing device', async () => { + const existingDevice = { + id: 1, + apiKey: 'existing-key-123', + friendlyId: 'swift-fox-42', + macAddress: 'AA:BB:CC:DD:EE:FF', + firmwareVersion: '1.0.0', + battery: 80, + wifi: -50, + model: { name: 'og_png' }, + }; + + mockPrisma.blockedDevice.findUnique.mockResolvedValue(null); + mockPrisma.device.findUnique.mockResolvedValue(existingDevice); + mockPrisma.device.update.mockResolvedValue({ + ...existingDevice, + firmwareVersion: '2.0.0', + }); + + const result = await service.provisionDevice( + 'AA:BB:CC:DD:EE:FF', + '2.0.0', + { battery: 90, wifi: -45 }, + 'http://localhost:3002', + ); + + expect(result.api_key).toBe('existing-key-123'); + expect(mockPrisma.device.update.calls).toHaveLength(1); + }); + + it('should create new device with default model when device does not exist', async () => { + const defaultModel = { id: 1, name: 'og_png' }; + const newDevice = { + id: 2, + apiKey: 'new-key-456', + friendlyId: 'bold-hawk-7', + macAddress: 'AA:BB:CC:DD:EE:FF', + model: defaultModel, + }; + + mockPrisma.blockedDevice.findUnique.mockResolvedValue(null); + mockPrisma.device.findUnique.mockResolvedValue(null); + mockPrisma.model.findFirst.mockResolvedValue(defaultModel); + mockPrisma.device.create.mockResolvedValue(newDevice); + + const result = await service.provisionDevice( + 'AA:BB:CC:DD:EE:FF', + '1.0.0', + { battery: 100, wifi: -30 }, + 'http://localhost:3002', + ); + + expect(result.api_key).toBe('new-key-456'); + expect(result.friendly_id).toBe('bold-hawk-7'); + expect(mockPrisma.device.create.calls).toHaveLength(1); + }); + + it('should auto-create default model if it does not exist', async () => { + const createdModel = { id: 1, name: 'og_png' }; + const newDevice = { + id: 1, + apiKey: 'key-789', + friendlyId: 'calm-wolf-3', + macAddress: 'AA:BB:CC:DD:EE:FF', + model: createdModel, + }; + + mockPrisma.blockedDevice.findUnique.mockResolvedValue(null); + mockPrisma.device.findUnique.mockResolvedValue(null); + mockPrisma.model.findFirst.mockResolvedValue(null); + mockPrisma.model.create.mockResolvedValue(createdModel); + mockPrisma.device.create.mockResolvedValue(newDevice); + + await service.provisionDevice('AA:BB:CC:DD:EE:FF'); + + expect(mockPrisma.model.create.calls).toHaveLength(1); + expect(mockPrisma.model.create.calls[0][0].data.name).toBe('og_png'); + }); + }); + + // ─── buildSetupResponse (private) ────────────────────────────────── + + describe('buildSetupResponse()', () => { + it('should include api_key, friendly_id, image_url, and message', () => { + const device = { + apiKey: 'test-api-key', + friendlyId: 'keen-eagle-55', + }; + + const result = (service as any).buildSetupResponse(device, 'http://myhost:3002'); + + expect(result.api_key).toBe('test-api-key'); + expect(result.friendly_id).toBe('keen-eagle-55'); + expect(result.image_url).toBe('http://myhost:3002/uploads/setup.png'); + expect(result.message).toBe('Welcome to Inker!'); + }); + + it('should fall back to API_URL env or localhost when no base URL set', () => { + const originalApiUrl = process.env.API_URL; + delete process.env.API_URL; + + const result = (service as any).buildSetupResponse({ + apiKey: 'key', + friendlyId: 'id', + }); + + expect(result.image_url).toContain('http://localhost:3002'); + + if (originalApiUrl !== undefined) { + process.env.API_URL = originalApiUrl; + } + }); + }); +}); diff --git a/backend/src/api/setup/setup.service.ts b/backend/src/api/setup/setup.service.ts new file mode 100644 index 0000000..f4b8701 --- /dev/null +++ b/backend/src/api/setup/setup.service.ts @@ -0,0 +1,228 @@ +import { + Injectable, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { generateToken } from '../../common/utils/crypto.util'; +import { SetupScreenService } from './setup-screen.service'; + +/** + * Device metrics from headers + */ +export interface DeviceMetrics { + battery?: number; // Battery percentage (0-100) + wifi?: number; // WiFi RSSI in dBm (e.g., -51) +} + +@Injectable() +export class SetupService { + private readonly logger = new Logger(SetupService.name); + + constructor( + private prisma: PrismaService, + private setupScreenService: SetupScreenService, + ) {} + + /** + * Provision device using MAC address + * Returns device UUID (API key) and configuration + * Compatible with Ruby Inker setup endpoint + * + * @param macAddress - Device MAC address + * @param firmwareVersion - Device firmware version (optional) + * @param metrics - Device metrics (battery, wifi) + * @param baseUrl - Dynamic base URL from request (e.g., "http://localhost:3002") + */ + async provisionDevice( + macAddress: string, + firmwareVersion?: string, + metrics?: DeviceMetrics, + baseUrl?: string, + ) { + // Validate MAC address format + if (!this.isValidMacAddress(macAddress)) { + throw new BadRequestException('Invalid MAC address format'); + } + + // Check if device was deleted/blocked - return reset signal instead of provisioning + // This prevents auto-re-provisioning of deleted devices + const blockedDevice = await this.prisma.blockedDevice.findUnique({ + where: { macAddress }, + }); + + if (blockedDevice) { + this.logger.log(`Device ${macAddress} is blocked - sending factory reset signal and unblocking`); + + // Remove from blocked list so device can re-provision after factory reset + await this.prisma.blockedDevice.delete({ + where: { macAddress }, + }); + + return { + status: 200, + api_key: '', + friendly_id: '', + image_url: '', + message: 'Device was removed from server', + reset_firmware: true, + }; + } + + // Check if device already exists + let device = await this.prisma.device.findUnique({ + where: { macAddress }, + include: { + model: true, + }, + }); + + if (device) { + // Device exists, update firmware version and metrics if provided + const updateData: { + firmwareVersion?: string; + lastSeenAt: Date; + battery?: number; + wifi?: number; + } = { + lastSeenAt: new Date(), + }; + + if (firmwareVersion && firmwareVersion !== device.firmwareVersion) { + updateData.firmwareVersion = firmwareVersion; + } + + // Update battery if provided + if (metrics?.battery !== undefined && !isNaN(metrics.battery)) { + updateData.battery = metrics.battery; + } + + // Update wifi RSSI if provided + if (metrics?.wifi !== undefined && !isNaN(metrics.wifi)) { + updateData.wifi = metrics.wifi; + } + + device = await this.prisma.device.update({ + where: { id: device.id }, + data: updateData, + include: { + model: true, + }, + }); + + this.logger.log( + `Device ${device.name} re-provisioned (MAC: ${macAddress}, battery: ${device.battery}%, wifi: ${device.wifi} dBm)`, + ); + + return this.buildSetupResponse(device, baseUrl); + } + + // Device doesn't exist, create new one + // Find default model (og_png) or create it if it doesn't exist + let defaultModel = await this.prisma.model.findFirst({ + where: { name: 'og_png' }, + }); + + // Auto-create the default model if it doesn't exist (for fresh databases) + if (!defaultModel) { + this.logger.log('Default model "og_png" not found, creating it...'); + defaultModel = await this.prisma.model.create({ + data: { + name: 'og_png', + label: 'TRMNL Original (PNG)', + width: 800, + height: 480, + description: 'Original TRMNL device with 800x480 e-ink display (PNG format)', + mimeType: 'image/png', + colors: 2, + bitDepth: 1, + rotation: 0, + offsetX: 0, + offsetY: 0, + kind: 'terminus', + scaleFactor: 1.0, + }, + }); + this.logger.log('Default model "og_png" created successfully'); + } + + // Generate API key (UUID) + const apiKey = generateToken(32); + + // Create new device with initial metrics if provided + device = await this.prisma.device.create({ + data: { + name: `Device-${macAddress.slice(-8)}`, + friendlyId: this.generateFriendlyId(), + macAddress, + apiKey, + firmwareVersion, + modelId: defaultModel.id, + lastSeenAt: new Date(), + refreshRate: 900, // 15 minutes default + wifi: metrics?.wifi !== undefined && !isNaN(metrics.wifi) ? metrics.wifi : 0, + battery: metrics?.battery !== undefined && !isNaN(metrics.battery) ? metrics.battery : 0, + }, + include: { + model: true, + }, + }); + + if (!device) { + throw new BadRequestException('Failed to create device'); + } + + this.logger.log( + `New device provisioned: ${device.name} (MAC: ${macAddress})`, + ); + + return this.buildSetupResponse(device, baseUrl); + } + + /** + * Build setup response compatible with Ruby Inker format + * Must match the exact format from firmware/setup.rb: + * { api_key, friendly_id, image_url, message } + */ + private buildSetupResponse(device: any, baseUrl?: string) { + // Use dynamic URL from request, or fall back to environment/default + const apiUrl = baseUrl || process.env.API_URL || 'http://localhost:3002'; + + // Get the setup screen URL from the SetupScreenService + const setupScreenUrl = this.setupScreenService.getSetupScreenUrl(); + + return { + status: 200, // TRMNL firmware expects status field + api_key: device.apiKey, // CRITICAL: Must be 'api_key' not 'uuid' + friendly_id: device.friendlyId, // Friendly name for the device + image_url: `${apiUrl}${setupScreenUrl}`, // Setup screen image + message: 'Welcome to Inker!', // Welcome message + }; + } + + /** + * Validate MAC address format + */ + private isValidMacAddress(mac: string): boolean { + // Accept various MAC address formats: + // - AA:BB:CC:DD:EE:FF + // - AA-BB-CC-DD-EE-FF + // - AABBCCDDEEFF + const macRegex = /^([0-9A-Fa-f]{2}[:-]?){5}([0-9A-Fa-f]{2})$/; + return macRegex.test(mac); + } + + /** + * Generate a friendly ID for device + */ + private generateFriendlyId(): string { + const adjectives = ['swift', 'bright', 'calm', 'bold', 'wise', 'keen']; + const nouns = ['fox', 'hawk', 'wolf', 'bear', 'lion', 'eagle']; + + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const num = Math.floor(Math.random() * 100); + + return `${adj}-${noun}-${num}`; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..f850126 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,106 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { BullModule } from '@nestjs/bullmq'; +import { TerminusModule } from '@nestjs/terminus'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { DevicesModule } from './devices/devices.module'; +import { ModelsModule } from './models/models.module'; +import { ScreensModule } from './screens/screens.module'; +import { PlaylistsModule } from './playlists/playlists.module'; +import { ExtensionsModule } from './extensions/extensions.module'; +import { DataSourcesModule } from './data-sources/data-sources.module'; +import { CustomWidgetsModule } from './custom-widgets/custom-widgets.module'; +import { FirmwareModule } from './firmware/firmware.module'; +import { HealthModule } from './health/health.module'; +import { DashboardModule } from './dashboard/dashboard.module'; +import { ApiModule } from './api/api.module'; +import { JobsModule } from './jobs/jobs.module'; +import { CommonModule } from './common/common.module'; +import { ScreenDesignerModule } from './screen-designer/screen-designer.module'; +import { EventsModule } from './events/events.module'; +import { SettingsModule } from './settings/settings.module'; +import { configuration } from './config/configuration'; +import { validationSchema } from './config/validation.schema'; + +@Module({ + imports: [ + // Configuration + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + validationSchema, + envFilePath: ['.env.local', '.env'], + }), + + // Rate limiting + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => [{ + ttl: config.get('throttle.ttl', 60) * 1000, + limit: config.get('throttle.limit', 100), + }], + }), + + // Queue management (BullMQ) + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + connection: { + host: config.get('redis.host', 'localhost'), + port: config.get('redis.port', 6379), + password: config.get('redis.password'), + }, + }), + }), + + // Health checks + TerminusModule, + + // Serve static files (uploaded widget images) + // Use process.cwd() for consistent path resolution in Docker + ServeStaticModule.forRoot({ + rootPath: join(process.cwd(), 'uploads'), + serveRoot: '/uploads', + serveStaticOptions: { + index: false, + }, + }), + + // Core modules + PrismaModule, + CommonModule, + HealthModule, + + // Feature modules + AuthModule, + ApiModule, + DevicesModule, + ModelsModule, + ScreensModule, + PlaylistsModule, + ExtensionsModule, + DataSourcesModule, + CustomWidgetsModule, + FirmwareModule, + DashboardModule, + JobsModule, + ScreenDesignerModule, + EventsModule, + SettingsModule, + ], + controllers: [], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], +}) +export class AppModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.controller.test.ts b/backend/src/auth/auth.controller.test.ts new file mode 100644 index 0000000..20e4998 --- /dev/null +++ b/backend/src/auth/auth.controller.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { AuthController } from './auth.controller'; +import { PinAuthService } from './pin-auth.service'; + +describe('AuthController (e2e)', () => { + let app: INestApplication; + + const mockPinAuthService = { + validatePin: (pin: string) => pin === '1234', + generateSessionToken: () => 'test-session-token', + invalidateSession: () => {}, + validateSession: (token: string) => token === 'valid-token', + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: PinAuthService, useValue: mockPinAuthService }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('POST /auth/login', () => { + it('should return token for valid PIN', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/login') + .send({ pin: '1234' }) + .expect(200); + + expect(response.body.token).toBe('test-session-token'); + expect(response.body.message).toBe('Login successful'); + }); + + it('should return 401 for invalid PIN', async () => { + await request(app.getHttpServer()) + .post('/auth/login') + .send({ pin: '0000' }) + .expect(401); + }); + }); + + describe('POST /auth/logout', () => { + it('should return success', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/logout') + .set('Authorization', 'Bearer test-token') + .expect(200); + + expect(response.body.message).toBe('Logout successful'); + }); + }); + + describe('POST /auth/validate', () => { + it('should return valid for valid token', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/validate') + .set('Authorization', 'Bearer valid-token') + .expect(200); + + expect(response.body.valid).toBe(true); + }); + + it('should return 401 for missing header', async () => { + await request(app.getHttpServer()) + .post('/auth/validate') + .expect(401); + }); + + it('should return 401 for invalid token format', async () => { + await request(app.getHttpServer()) + .post('/auth/validate') + .set('Authorization', 'InvalidFormat') + .expect(401); + }); + + it('should return 401 for expired session', async () => { + await request(app.getHttpServer()) + .post('/auth/validate') + .set('Authorization', 'Bearer expired-token') + .expect(401); + }); + }); +}); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..a169535 --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, + UnauthorizedException, + Headers, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PinAuthService } from './pin-auth.service'; +import { LoginDto } from './dto/login.dto'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly pinAuthService: PinAuthService) {} + + @Public() + @Post('login') + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login with PIN' }) + @ApiResponse({ status: 200, description: 'Successfully authenticated' }) + @ApiResponse({ status: 401, description: 'Invalid PIN' }) + async login(@Body() loginDto: LoginDto) { + const isValid = this.pinAuthService.validatePin(loginDto.pin); + + if (!isValid) { + throw new UnauthorizedException('Invalid PIN'); + } + + const token = this.pinAuthService.generateSessionToken(); + + return { + token, + message: 'Login successful', + }; + } + + @Public() + @Post('logout') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Logout and invalidate session' }) + @ApiResponse({ status: 200, description: 'Successfully logged out' }) + async logout(@Headers('authorization') authHeader: string) { + if (authHeader) { + const [, token] = authHeader.split(' '); + if (token) { + this.pinAuthService.invalidateSession(token); + } + } + + return { message: 'Logout successful' }; + } + + @Public() + @Post('validate') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Validate current session' }) + @ApiResponse({ status: 200, description: 'Session valid' }) + @ApiResponse({ status: 401, description: 'Session invalid' }) + async validate(@Headers('authorization') authHeader: string) { + if (!authHeader) { + throw new UnauthorizedException('No session token'); + } + + const [type, token] = authHeader.split(' '); + if (type !== 'Bearer' || !token) { + throw new UnauthorizedException('Invalid token format'); + } + + const isValid = this.pinAuthService.validateSession(token); + if (!isValid) { + throw new UnauthorizedException('Session expired or invalid'); + } + + return { valid: true }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..f6282f9 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { PinAuthService } from './pin-auth.service'; +import { AuthController } from './auth.controller'; +import { PinAuthGuard } from './guards/pin-auth.guard'; + +@Module({ + controllers: [AuthController], + providers: [ + PinAuthService, + { + provide: APP_GUARD, + useClass: PinAuthGuard, + }, + ], + exports: [PinAuthService], +}) +export class AuthModule {} diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..d7b74a6 --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength, MaxLength } from 'class-validator'; + +export class LoginDto { + @ApiProperty({ example: 'your-pin-here', description: 'Admin PIN for authentication' }) + @IsString() + @MinLength(1) + @MaxLength(128) // Prevent DOS with extremely long PINs + pin: string; +} diff --git a/backend/src/auth/guards/pin-auth.guard.test.ts b/backend/src/auth/guards/pin-auth.guard.test.ts new file mode 100644 index 0000000..9725a06 --- /dev/null +++ b/backend/src/auth/guards/pin-auth.guard.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { PinAuthGuard } from './pin-auth.guard'; +import { createMock } from '../../test/mocks/helpers'; +import { UnauthorizedException } from '@nestjs/common'; + +describe('PinAuthGuard', () => { + let guard: PinAuthGuard; + let mockReflector: any; + let mockPinAuthService: any; + + beforeEach(() => { + mockReflector = { + getAllAndOverride: createMock().mockReturnValue(false), + }; + mockPinAuthService = { + validateSession: createMock().mockReturnValue(true), + }; + guard = new PinAuthGuard(mockReflector, mockPinAuthService); + }); + + const createMockContext = (authHeader?: string) => ({ + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ + headers: authHeader ? { authorization: authHeader } : {}, + }), + }), + }); + + it('should allow public routes', () => { + mockReflector.getAllAndOverride.mockReturnValue(true); + const context = createMockContext(); + expect(guard.canActivate(context as any)).toBe(true); + }); + + it('should throw when no authorization header', () => { + const context = createMockContext(); + expect(() => guard.canActivate(context as any)).toThrow(UnauthorizedException); + }); + + it('should throw when token format is invalid', () => { + const context = createMockContext('InvalidFormat'); + expect(() => guard.canActivate(context as any)).toThrow(UnauthorizedException); + }); + + it('should throw when session is invalid', () => { + mockPinAuthService.validateSession.mockReturnValue(false); + const context = createMockContext('Bearer invalid-token'); + expect(() => guard.canActivate(context as any)).toThrow(UnauthorizedException); + }); + + it('should allow valid session token', () => { + const context = createMockContext('Bearer valid-token'); + expect(guard.canActivate(context as any)).toBe(true); + expect(mockPinAuthService.validateSession.calls[0][0]).toBe('valid-token'); + }); +}); diff --git a/backend/src/auth/guards/pin-auth.guard.ts b/backend/src/auth/guards/pin-auth.guard.ts new file mode 100644 index 0000000..f2b67fc --- /dev/null +++ b/backend/src/auth/guards/pin-auth.guard.ts @@ -0,0 +1,59 @@ +import { Injectable, ExecutionContext, CanActivate, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator'; +import { PinAuthService } from '../pin-auth.service'; + +/** + * PIN Authentication Guard + * Protects routes by default unless marked with @Public() decorator + * Validates session token from Authorization header + */ +@Injectable() +export class PinAuthGuard implements CanActivate { + constructor( + private reflector: Reflector, + private pinAuthService: PinAuthService, + ) {} + + canActivate(context: ExecutionContext): boolean { + // Check if route is marked as public + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + // Extract token from Authorization header + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No session token provided'); + } + + // Validate session token + const isValid = this.pinAuthService.validateSession(token); + if (!isValid) { + throw new UnauthorizedException('Invalid or expired session'); + } + + return true; + } + + private extractToken(request: any): string | null { + const authHeader = request.headers.authorization; + if (!authHeader) { + return null; + } + + const [type, token] = authHeader.split(' '); + if (type !== 'Bearer' || !token) { + return null; + } + + return token; + } +} diff --git a/backend/src/auth/pin-auth.service.test.ts b/backend/src/auth/pin-auth.service.test.ts new file mode 100644 index 0000000..cd01f95 --- /dev/null +++ b/backend/src/auth/pin-auth.service.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { PinAuthService } from './pin-auth.service'; + +// Mock ConfigService +const createMockConfigService = (pin?: string) => ({ + get: (key: string) => { + if (key === 'admin.pin') return pin; + return undefined; + }, +}); + +describe('PinAuthService', () => { + let service: PinAuthService; + + beforeEach(() => { + service = new PinAuthService(createMockConfigService('1234') as any); + }); + + describe('validatePin', () => { + it('should return true for correct PIN', () => { + expect(service.validatePin('1234')).toBe(true); + }); + + it('should return false for incorrect PIN', () => { + expect(service.validatePin('0000')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(service.validatePin('')).toBe(false); + }); + + it('should return false when stored PIN is undefined', () => { + const serviceNoPin = new PinAuthService(createMockConfigService(undefined) as any); + expect(serviceNoPin.validatePin('1234')).toBe(false); + }); + }); + + describe('generateSessionToken', () => { + it('should return a 64-character hex string', () => { + const token = service.generateSessionToken(); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[a-f0-9]+$/); + }); + + it('should generate unique tokens', () => { + const token1 = service.generateSessionToken(); + const token2 = service.generateSessionToken(); + expect(token1).not.toBe(token2); + }); + }); + + describe('validateSession', () => { + it('should return true for a generated token', () => { + const token = service.generateSessionToken(); + expect(service.validateSession(token)).toBe(true); + }); + + it('should return false for unknown token', () => { + expect(service.validateSession('nonexistent')).toBe(false); + }); + }); + + describe('invalidateSession', () => { + it('should remove the session', () => { + const token = service.generateSessionToken(); + expect(service.validateSession(token)).toBe(true); + service.invalidateSession(token); + expect(service.validateSession(token)).toBe(false); + }); + }); + + describe('cleanupExpiredSessions', () => { + it('should remove sessions older than 30 days', () => { + const token = service.generateSessionToken(); + expect(service.validateSession(token)).toBe(true); + + // Manipulate the session's createdAt to be 31 days ago + const sessions = (service as any).sessions as Map; + const session = sessions.get(token)!; + session.createdAt = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + + service.cleanupExpiredSessions(); + expect(service.validateSession(token)).toBe(false); + }); + + it('should keep sessions younger than 30 days', () => { + const token = service.generateSessionToken(); + service.cleanupExpiredSessions(); + expect(service.validateSession(token)).toBe(true); + }); + }); +}); diff --git a/backend/src/auth/pin-auth.service.ts b/backend/src/auth/pin-auth.service.ts new file mode 100644 index 0000000..f073da7 --- /dev/null +++ b/backend/src/auth/pin-auth.service.ts @@ -0,0 +1,110 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomBytes, timingSafeEqual } from 'crypto'; + +/** + * Simple PIN-based authentication service + * Replaces complex user/JWT auth with a single shared PIN + */ +@Injectable() +export class PinAuthService implements OnModuleInit, OnModuleDestroy { + // In-memory session storage (simple approach for single-instance) + private sessions = new Map(); + private cleanupInterval: ReturnType | null = null; + + // Cleanup interval: 1 hour + private static readonly CLEANUP_INTERVAL_MS = 60 * 60 * 1000; + // Session max age: 30 days + private static readonly SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + + constructor(private configService: ConfigService) {} + + /** + * Start periodic session cleanup on module init + */ + onModuleInit() { + this.cleanupInterval = setInterval(() => { + this.cleanupExpiredSessions(); + }, PinAuthService.CLEANUP_INTERVAL_MS); + } + + /** + * Clear cleanup interval on module destroy + */ + onModuleDestroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Validate PIN against configured admin PIN + * Uses timing-safe comparison to prevent timing attacks + */ + validatePin(pin: string): boolean { + const storedPin = this.configService.get('admin.pin'); + + // Guard against undefined/null storedPin + if (!storedPin) { + return false; + } + + // Use timing-safe comparison to prevent timing attacks + // Pad both strings to the same length to avoid length-based timing leaks + const maxLen = Math.max(pin.length, storedPin.length); + const paddedPin = pin.padEnd(maxLen, '\0'); + const paddedStored = storedPin.padEnd(maxLen, '\0'); + + try { + return timingSafeEqual(Buffer.from(paddedPin), Buffer.from(paddedStored)); + } catch { + return false; + } + } + + /** + * Generate a new session token + */ + generateSessionToken(): string { + const token = randomBytes(32).toString('hex'); + this.sessions.set(token, { createdAt: new Date() }); + return token; + } + + /** + * Validate session token (also checks expiry on every request) + */ + validateSession(token: string): boolean { + const session = this.sessions.get(token); + if (!session) return false; + + // Check expiry on every validation, not just during cleanup + if (session.createdAt.getTime() < Date.now() - PinAuthService.SESSION_MAX_AGE_MS) { + this.sessions.delete(token); + return false; + } + + return true; + } + + /** + * Invalidate session token (logout) + */ + invalidateSession(token: string): void { + this.sessions.delete(token); + } + + /** + * Clean up expired sessions (optional, for memory management) + * Sessions older than 30 days are removed + */ + cleanupExpiredSessions(): void { + const cutoff = new Date(Date.now() - PinAuthService.SESSION_MAX_AGE_MS); + for (const [token, session] of this.sessions.entries()) { + if (session.createdAt < cutoff) { + this.sessions.delete(token); + } + } + } +} diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts new file mode 100644 index 0000000..ad3fdb1 --- /dev/null +++ b/backend/src/common/common.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class CommonModule {} \ No newline at end of file diff --git a/backend/src/common/constants/widget.constants.ts b/backend/src/common/constants/widget.constants.ts new file mode 100644 index 0000000..e55c4c9 --- /dev/null +++ b/backend/src/common/constants/widget.constants.ts @@ -0,0 +1,10 @@ +/** + * Widget-related constants + */ + +/** + * Offset used for custom widget virtual template IDs + * Custom widgets get IDs: CUSTOM_WIDGET_TEMPLATE_OFFSET + customWidget.id + * This prevents collision with built-in template IDs (typically 1-100) + */ +export const CUSTOM_WIDGET_TEMPLATE_OFFSET = 10000; diff --git a/backend/src/common/decorators/public.decorator.ts b/backend/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..002730c --- /dev/null +++ b/backend/src/common/decorators/public.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * Decorator to mark routes as public (bypass JWT authentication) + * Usage: @Public() + */ +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..878807b --- /dev/null +++ b/backend/src/common/filters/http-exception.filter.ts @@ -0,0 +1,61 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.getResponse() + : 'Internal server error'; + + const safePath = this.sanitizeUrl(request.url); + + const errorResponse = { + statusCode: status, + timestamp: new Date().toISOString(), + path: safePath, + method: request.method, + message: typeof message === 'string' ? message : (message as any).message, + ...(typeof message === 'object' && message !== null ? message : {}), + }; + + // Log error details (query params stripped to prevent token leakage) + if (status >= 500) { + this.logger.error( + `${request.method} ${safePath}`, + exception instanceof Error ? exception.stack : JSON.stringify(exception), + ); + } else { + this.logger.warn( + `${request.method} ${safePath} - ${JSON.stringify(errorResponse)}`, + ); + } + + response.status(status).json(errorResponse); + } + + /** Strip query parameters from URL to prevent token leakage in logs */ + private sanitizeUrl(url: string): string { + const qIndex = url.indexOf('?'); + return qIndex >= 0 ? url.substring(0, qIndex) : url; + } +} \ No newline at end of file diff --git a/backend/src/common/interceptors/transform.interceptor.ts b/backend/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..c5871ac --- /dev/null +++ b/backend/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,56 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + data: T; +} + +/** + * Transform interceptor that wraps successful responses in a standard format + */ +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const request = context.switchToHttp().getRequest(); + const path = request?.url || ''; + + // Device API endpoints (/api/setup, /api/display, /api/log) return raw responses + // to match Ruby Inker API format (no {data: ...} wrapper) + const isDeviceApi = path.startsWith('/api/setup') || + path.startsWith('/api/display') || + path.startsWith('/api/log'); + + return next.handle().pipe( + map((data) => { + // Return raw response for device API endpoints + if (isDeviceApi) { + return data; + } + + // Don't transform if already wrapped with ONLY 'data' as the key + // This prevents skipping wrap when response happens to contain a 'data' field + // among other fields (like testUrl returning { success, data, fields }) + if ( + data && + typeof data === 'object' && + 'data' in data && + Object.keys(data).length === 1 + ) { + return data; + } + return { data }; + }), + ); + } +} \ No newline at end of file diff --git a/backend/src/common/utils/crypto.util.test.ts b/backend/src/common/utils/crypto.util.test.ts new file mode 100644 index 0000000..f50be3d --- /dev/null +++ b/backend/src/common/utils/crypto.util.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'bun:test'; +import { generateApiKey, generateToken } from './crypto.util'; + +describe('crypto.util', () => { + describe('generateApiKey', () => { + it('should return a hex string of correct length', () => { + const key = generateApiKey(); + expect(key).toMatch(/^[a-f0-9]+$/); + expect(key).toHaveLength(64); // 32 bytes = 64 hex chars + }); + + it('should respect custom length', () => { + const key = generateApiKey(16); + expect(key).toHaveLength(32); // 16 bytes = 32 hex chars + }); + + it('should generate unique keys', () => { + const key1 = generateApiKey(); + const key2 = generateApiKey(); + expect(key1).not.toBe(key2); + }); + }); + + describe('generateToken', () => { + it('should return a base64url string', () => { + const token = generateToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('should generate unique tokens', () => { + const token1 = generateToken(); + const token2 = generateToken(); + expect(token1).not.toBe(token2); + }); + }); +}); diff --git a/backend/src/common/utils/crypto.util.ts b/backend/src/common/utils/crypto.util.ts new file mode 100644 index 0000000..b9cdd79 --- /dev/null +++ b/backend/src/common/utils/crypto.util.ts @@ -0,0 +1,15 @@ +import { randomBytes } from 'crypto'; + +/** + * Generate a random API key + */ +export function generateApiKey(length: number = 32): string { + return randomBytes(length).toString('hex'); +} + +/** + * Generate a random token + */ +export function generateToken(length: number = 32): string { + return randomBytes(length).toString('base64url'); +} \ No newline at end of file diff --git a/backend/src/common/utils/response.util.test.ts b/backend/src/common/utils/response.util.test.ts new file mode 100644 index 0000000..e744fd8 --- /dev/null +++ b/backend/src/common/utils/response.util.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'bun:test'; +import { + wrapListResponse, + wrapPaginatedResponse, + wrapSingleResponse, + wrapMessageResponse, +} from './response.util'; + +describe('response.util', () => { + describe('wrapListResponse', () => { + it('should wrap items with total count', () => { + const items = [{ id: 1 }, { id: 2 }]; + const result = wrapListResponse(items); + expect(result).toEqual({ + data: { items, total: 2 }, + }); + }); + + it('should handle empty array', () => { + const result = wrapListResponse([]); + expect(result).toEqual({ + data: { items: [], total: 0 }, + }); + }); + }); + + describe('wrapPaginatedResponse', () => { + it('should include pagination metadata', () => { + const items = [{ id: 1 }]; + const result = wrapPaginatedResponse(items, 10, 1, 5); + expect(result.data.items).toEqual(items); + expect(result.data.total).toBe(10); + expect(result.data.page).toBe(1); + expect(result.data.limit).toBe(5); + }); + + it('should set hasMore true when more pages exist', () => { + const result = wrapPaginatedResponse([], 10, 1, 5); + expect(result.data.hasMore).toBe(true); + }); + + it('should set hasMore false on last page', () => { + const result = wrapPaginatedResponse([], 10, 2, 5); + expect(result.data.hasMore).toBe(false); + }); + + it('should set hasMore false when total equals page*limit', () => { + const result = wrapPaginatedResponse([], 6, 2, 3); + expect(result.data.hasMore).toBe(false); + }); + }); + + describe('wrapSingleResponse', () => { + it('should wrap a single item', () => { + const item = { id: 1, name: 'test' }; + expect(wrapSingleResponse(item)).toEqual({ data: item }); + }); + }); + + describe('wrapMessageResponse', () => { + it('should wrap a message string', () => { + expect(wrapMessageResponse('ok')).toEqual({ message: 'ok' }); + }); + }); +}); diff --git a/backend/src/common/utils/response.util.ts b/backend/src/common/utils/response.util.ts new file mode 100644 index 0000000..a9eef0c --- /dev/null +++ b/backend/src/common/utils/response.util.ts @@ -0,0 +1,97 @@ +/** + * Response wrapper utility for consistent API responses across all endpoints + * + * This ensures that list endpoints return data in the format: + * { + * data: { + * items: [...], + * total: number + * } + * } + */ + +export interface ListResponse { + data: { + items: T[]; + total: number; + }; +} + +export interface PaginatedResponse { + data: { + items: T[]; + total: number; + page: number; + limit: number; + hasMore: boolean; + }; +} + +export interface SingleResponse { + data: T; +} + +export interface MessageResponse { + message: string; +} + +/** + * Wraps an array of items into a consistent list response format + * @param items - Array of items to return + * @returns Standardized list response with items and total count + */ +export function wrapListResponse(items: T[]): ListResponse { + return { + data: { + items, + total: items.length, + }, + }; +} + +/** + * Wraps an array of items into a paginated response format + * @param items - Array of items for the current page + * @param total - Total count of all items (not just this page) + * @param page - Current page number (1-indexed) + * @param limit - Number of items per page + * @returns Standardized paginated response + */ +export function wrapPaginatedResponse( + items: T[], + total: number, + page: number, + limit: number, +): PaginatedResponse { + return { + data: { + items, + total, + page, + limit, + hasMore: page * limit < total, + }, + }; +} + +/** + * Wraps a single item into a consistent response format + * @param item - Single item to return + * @returns Standardized single item response + */ +export function wrapSingleResponse(item: T): SingleResponse { + return { + data: item, + }; +} + +/** + * Creates a message response + * @param message - Message to return + * @returns Standardized message response + */ +export function wrapMessageResponse(message: string): MessageResponse { + return { + message, + }; +} diff --git a/backend/src/common/utils/url-safety.ts b/backend/src/common/utils/url-safety.ts new file mode 100644 index 0000000..7577691 --- /dev/null +++ b/backend/src/common/utils/url-safety.ts @@ -0,0 +1,175 @@ +/** + * URL Safety Utility + * Shared SSRF validation for any HTTP fetch in the application. + * Prevents requests to internal services, private IPs, and non-HTTP protocols. + */ +import * as dns from 'dns'; +import * as http from 'http'; +import * as https from 'https'; +import { promisify } from 'util'; + +const dnsResolve4 = promisify(dns.resolve4); +const dnsResolve6 = promisify(dns.resolve6); + +/** Hostnames that must never be accessed by user-initiated fetches */ +const BLOCKED_HOSTNAMES = [ + 'localhost', + 'inker-backend', + 'inker-frontend', + 'inker-postgres', + 'inker-redis', + 'host.docker.internal', + 'metadata.google.internal', +]; + +/** + * Check whether an IP address belongs to a private/reserved range. + */ +export function isPrivateIp(ip: string): boolean { + // Handle IPv4-mapped IPv6 (e.g., ::ffff:127.0.0.1) + const ipv4Mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (ipv4Mapped) return isPrivateIp(ipv4Mapped[1]); + + // IPv4 private ranges + const parts = ip.split('.').map(Number); + if (parts.length === 4 && parts.every((p) => !isNaN(p))) { + const [a, b] = parts; + return ( + a === 127 || // 127.0.0.0/8 (loopback) + a === 10 || // 10.0.0.0/8 + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 + (a === 192 && b === 168) || // 192.168.0.0/16 + (a === 169 && b === 254) || // 169.254.0.0/16 (link-local / cloud metadata) + a === 0 // 0.0.0.0/8 + ); + } + + // IPv6 private/reserved ranges + const normalized = ip.toLowerCase(); + if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; + if (/^f[cd]/.test(normalized)) return true; // fc00::/7 (Unique Local Addresses) + if (/^fe[89ab]/.test(normalized)) return true; // fe80::/10 (Link-Local) + + return false; +} + +/** + * Validate a URL for safe external fetching. + * Throws an Error if the URL is unsafe (private IP, blocked hostname, bad protocol). + */ +export async function validateUrlSafety(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error('Invalid URL format'); + } + + // Only allow http/https + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Only HTTP and HTTPS URLs are allowed'); + } + + const hostname = parsed.hostname.toLowerCase(); + + // Block internal hostnames + if (BLOCKED_HOSTNAMES.includes(hostname)) { + throw new Error('URLs pointing to internal services are not allowed'); + } + + // Check if hostname is directly an IP address in private range + if (isPrivateIp(hostname)) { + throw new Error('URLs pointing to private IP addresses are not allowed'); + } + + // Resolve DNS and check resolved IPs + try { + const [ipv4Addresses, ipv6Addresses] = await Promise.all([ + dnsResolve4(hostname).catch(() => [] as string[]), + dnsResolve6(hostname).catch(() => [] as string[]), + ]); + const allAddresses = [...ipv4Addresses, ...ipv6Addresses]; + for (const addr of allAddresses) { + if (isPrivateIp(addr)) { + throw new Error('URL resolves to a private IP address'); + } + } + } catch (err) { + if (err instanceof Error && ( + err.message.includes('private IP') || + err.message.includes('internal services') || + err.message.includes('Only HTTP') + )) { + throw err; + } + // Other DNS errors (e.g., ENOTFOUND) are fine — the fetch will handle them + } +} + +/** + * Create an http.Agent that validates DNS resolution against private IPs. + * This prevents DNS rebinding attacks (TOCTOU between validateUrlSafety and actual fetch). + */ +export function createSafeHttpAgent(): http.Agent { + return new http.Agent({ + lookup: (hostname: string, options: dns.LookupOptions, callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void) => { + dns.resolve4(hostname, (err, addresses) => { + if (err) { + // Fall back to dns.lookup for non-A-record hostnames + dns.lookup(hostname, { family: 4 }, (lookupErr, addr, fam) => { + if (lookupErr) return callback(lookupErr, '', 4); + if (isPrivateIp(addr)) { + return callback( + Object.assign(new Error(`DNS resolved to private IP: ${addr}`), { code: 'ECONNREFUSED' }), + '', 4, + ); + } + callback(null, addr, fam); + }); + return; + } + const addr = addresses[0]; + if (isPrivateIp(addr)) { + return callback( + Object.assign(new Error(`DNS resolved to private IP: ${addr}`), { code: 'ECONNREFUSED' }), + '', 4, + ); + } + callback(null, addr, 4); + }); + }, + } as any); +} + +/** + * Create an https.Agent that validates DNS resolution against private IPs. + */ +export function createSafeHttpsAgent(): https.Agent { + return new https.Agent({ + lookup: (hostname: string, options: dns.LookupOptions, callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void) => { + dns.resolve4(hostname, (err, addresses) => { + if (err) { + dns.lookup(hostname, { family: 4 }, (lookupErr, addr, fam) => { + if (lookupErr) return callback(lookupErr, '', 4); + if (isPrivateIp(addr)) { + return callback( + Object.assign(new Error(`DNS resolved to private IP: ${addr}`), { code: 'ECONNREFUSED' }), + '', 4, + ); + } + callback(null, addr, fam); + }); + return; + } + const addr = addresses[0]; + if (isPrivateIp(addr)) { + return callback( + Object.assign(new Error(`DNS resolved to private IP: ${addr}`), { code: 'ECONNREFUSED' }), + '', 4, + ); + } + callback(null, addr, 4); + }); + }, + } as any); +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts new file mode 100644 index 0000000..ee569b7 --- /dev/null +++ b/backend/src/config/configuration.ts @@ -0,0 +1,76 @@ +export const configuration = () => ({ + port: parseInt(process.env.PORT || '3002', 10), + environment: process.env.NODE_ENV || 'development', + + api: { + url: process.env.API_URL || `http://localhost:${process.env.PORT || '3002'}`, + }, + + database: { + url: process.env.DATABASE_URL, + }, + + redis: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD, + }, + + // Background jobs configuration + jobs: { + firmwarePoller: { + enabled: process.env.FIRMWARE_POLLER_ENABLED !== 'false', + interval: parseInt( + process.env.FIRMWARE_POLLER_INTERVAL || '86400000', + 10, + ), // 24 hours + }, + modelPoller: { + enabled: process.env.MODEL_POLLER_ENABLED !== 'false', + interval: parseInt(process.env.MODEL_POLLER_INTERVAL || '86400000', 10), // 24 hours + }, + screenPoller: { + enabled: process.env.SCREEN_POLLER_ENABLED !== 'false', + interval: parseInt(process.env.SCREEN_POLLER_INTERVAL || '900000', 10), // 15 minutes + }, + }, + + // External APIs + models: { + apiUrl: process.env.MODELS_API_URL, + }, + + github: { + token: process.env.GITHUB_TOKEN, // Optional - increases rate limit from 60/hr to 5000/hr + }, + + throttle: { + ttl: parseInt(process.env.THROTTLE_TTL || '60', 10), + limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10), + }, + + upload: { + maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB + screensDir: process.env.SCREENS_DIR || './uploads/screens', + firmwareDir: process.env.FIRMWARE_DIR || './uploads/firmware', + }, + + device: { + apiKeyLength: 32, + pollingInterval: parseInt(process.env.DEVICE_POLLING_INTERVAL || '60000', 10), // 1 minute + offlineThreshold: parseInt(process.env.DEVICE_OFFLINE_THRESHOLD || '300000', 10), // 5 minutes + }, + + admin: { + pin: process.env.ADMIN_PIN || '1111', + }, + + logging: { + level: process.env.LOG_LEVEL || 'info', + format: process.env.LOG_FORMAT || 'json', + }, + + // Default timezone for server-side rendering (e.g., clock/date widgets) + // Docker containers default to UTC, so this provides a configurable default + defaultTimezone: process.env.DEFAULT_TIMEZONE || 'UTC', +}); \ No newline at end of file diff --git a/backend/src/config/logger.config.ts b/backend/src/config/logger.config.ts new file mode 100644 index 0000000..0d326ba --- /dev/null +++ b/backend/src/config/logger.config.ts @@ -0,0 +1,36 @@ +import { utilities as nestWinstonModuleUtilities } from 'nest-winston'; +import * as winston from 'winston'; + +export function createLoggerConfig() { + const format = process.env.LOG_FORMAT === 'simple' + ? winston.format.simple() + : winston.format.json(); + + return { + transports: [ + new winston.transports.Console({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.ms(), + nestWinstonModuleUtilities.format.nestLike('Inker', { + prettyPrint: process.env.NODE_ENV !== 'production', + }), + ), + }), + ...(process.env.NODE_ENV === 'production' + ? [ + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + format, + }), + new winston.transports.File({ + filename: 'logs/combined.log', + format, + }), + ] + : []), + ], + }; +} \ No newline at end of file diff --git a/backend/src/config/validation.schema.ts b/backend/src/config/validation.schema.ts new file mode 100644 index 0000000..805b037 --- /dev/null +++ b/backend/src/config/validation.schema.ts @@ -0,0 +1,35 @@ +import * as Joi from 'joi'; + +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid('development', 'production', 'test', 'staging') + .default('development'), + PORT: Joi.number().default(3000), + + // Database + DATABASE_URL: Joi.string().required(), + + // Redis + REDIS_HOST: Joi.string().default('localhost'), + REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().optional().allow(''), + + // Rate limiting + THROTTLE_TTL: Joi.number().default(60), + THROTTLE_LIMIT: Joi.number().default(100), + + // File uploads + MAX_FILE_SIZE: Joi.number().default(10485760), + SCREENS_DIR: Joi.string().default('./uploads/screens'), + FIRMWARE_DIR: Joi.string().default('./uploads/firmware'), + + // Device configuration + DEVICE_POLLING_INTERVAL: Joi.number().default(60000), + DEVICE_OFFLINE_THRESHOLD: Joi.number().default(300000), + + // Logging + LOG_LEVEL: Joi.string() + .valid('error', 'warn', 'info', 'debug', 'verbose') + .default('info'), + LOG_FORMAT: Joi.string().valid('json', 'simple').default('json'), +}); \ No newline at end of file diff --git a/backend/src/custom-widgets/custom-widgets.controller.test.ts b/backend/src/custom-widgets/custom-widgets.controller.test.ts new file mode 100644 index 0000000..383f1f2 --- /dev/null +++ b/backend/src/custom-widgets/custom-widgets.controller.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { CustomWidgetsController } from './custom-widgets.controller'; +import { CustomWidgetsService } from './custom-widgets.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('CustomWidgetsController (e2e)', () => { + let app: INestApplication; + + const mockCustomWidget = { + id: 1, + name: 'Test Widget', + description: 'A test custom widget', + displayType: 'value', + template: null, + config: { field: 'price' }, + dataSourceId: 1, + minWidth: 100, + minHeight: 50, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + dataSource: { id: 1, name: 'Test API', type: 'json', isActive: true }, + }; + + const mockCustomWidgetsService = { + create: async () => mockCustomWidget, + findAll: async () => ({ + items: [mockCustomWidget], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }), + findOne: async () => mockCustomWidget, + update: async () => ({ ...mockCustomWidget, name: 'Updated Widget' }), + remove: async () => ({ message: 'Custom widget deleted successfully' }), + getWithData: async () => ({ widget: mockCustomWidget, data: {}, renderedContent: 'test' }), + getAsWidgetTemplates: async () => [], + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [CustomWidgetsController], + providers: [ + { provide: CustomWidgetsService, useValue: mockCustomWidgetsService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /custom-widgets', () => { + it('should return paginated list of custom widgets', async () => { + const response = await request(app.getHttpServer()) + .get('/custom-widgets') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + expect(response.body.data).toHaveProperty('total'); + }); + }); + + describe('GET /custom-widgets/:id', () => { + it('should return a custom widget by ID', async () => { + const response = await request(app.getHttpServer()) + .get('/custom-widgets/1') + .expect(200); + + expect(response.body.data).toHaveProperty('id', 1); + expect(response.body.data).toHaveProperty('name', 'Test Widget'); + expect(response.body.data).toHaveProperty('dataSource'); + }); + }); + + describe('POST /custom-widgets', () => { + it('should create a new custom widget', async () => { + const response = await request(app.getHttpServer()) + .post('/custom-widgets') + .send({ name: 'New Widget', dataSourceId: 1, displayType: 'value' }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('displayType'); + }); + }); + + describe('DELETE /custom-widgets/:id', () => { + it('should delete a custom widget', async () => { + const response = await request(app.getHttpServer()) + .delete('/custom-widgets/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Custom widget deleted successfully'); + }); + }); +}); diff --git a/backend/src/custom-widgets/custom-widgets.controller.ts b/backend/src/custom-widgets/custom-widgets.controller.ts new file mode 100644 index 0000000..d17b30c --- /dev/null +++ b/backend/src/custom-widgets/custom-widgets.controller.ts @@ -0,0 +1,72 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { CustomWidgetsService } from './custom-widgets.service'; +import { CreateCustomWidgetDto } from './dto/create-custom-widget.dto'; +import { UpdateCustomWidgetDto } from './dto/update-custom-widget.dto'; + +@ApiTags('Custom Widgets') +@ApiBearerAuth() +@Controller('custom-widgets') +export class CustomWidgetsController { + constructor(private readonly customWidgetsService: CustomWidgetsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new custom widget' }) + create(@Body() createCustomWidgetDto: CreateCustomWidgetDto) { + return this.customWidgetsService.create(createCustomWidgetDto); + } + + @Get() + @ApiOperation({ summary: 'Get all custom widgets with pagination' }) + findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = Math.max(parseInt(page || '1', 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit || '20', 10) || 20, 1), 100); + return this.customWidgetsService.findAll(pageNum, limitNum); + } + + @Get('templates') + @ApiOperation({ summary: 'Get custom widgets as widget templates for screen designer' }) + getAsWidgetTemplates() { + return this.customWidgetsService.getAsWidgetTemplates(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a custom widget by ID' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.customWidgetsService.findOne(id); + } + + @Get(':id/preview') + @ApiOperation({ summary: 'Get custom widget with rendered data' }) + getWithData(@Param('id', ParseIntPipe) id: number) { + return this.customWidgetsService.getWithData(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a custom widget' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateCustomWidgetDto: UpdateCustomWidgetDto, + ) { + return this.customWidgetsService.update(id, updateCustomWidgetDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a custom widget' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.customWidgetsService.remove(id); + } +} diff --git a/backend/src/custom-widgets/custom-widgets.module.ts b/backend/src/custom-widgets/custom-widgets.module.ts new file mode 100644 index 0000000..55cb6c2 --- /dev/null +++ b/backend/src/custom-widgets/custom-widgets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { CustomWidgetsController } from './custom-widgets.controller'; +import { CustomWidgetsService } from './custom-widgets.service'; +import { ScriptExecutorService } from './services/script-executor.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { DataSourcesModule } from '../data-sources/data-sources.module'; + +@Module({ + imports: [PrismaModule, DataSourcesModule], + controllers: [CustomWidgetsController], + providers: [CustomWidgetsService, ScriptExecutorService], + exports: [CustomWidgetsService], +}) +export class CustomWidgetsModule {} diff --git a/backend/src/custom-widgets/custom-widgets.service.test.ts b/backend/src/custom-widgets/custom-widgets.service.test.ts new file mode 100644 index 0000000..9cc46fb --- /dev/null +++ b/backend/src/custom-widgets/custom-widgets.service.test.ts @@ -0,0 +1,537 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { CustomWidgetsService } from './custom-widgets.service'; +import { ScriptExecutorService } from './services/script-executor.service'; +import { createMockPrisma } from '../test/mocks/prisma.mock'; +import { createMock } from '../test/mocks/helpers'; +import { CUSTOM_WIDGET_TEMPLATE_OFFSET } from '../common/constants/widget.constants'; + +describe('CustomWidgetsService', () => { + let service: CustomWidgetsService; + let mockPrisma: ReturnType; + let mockDataSourcesService: { getCachedData: ReturnType }; + let scriptExecutor: ScriptExecutorService; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + mockDataSourcesService = { getCachedData: createMock() }; + scriptExecutor = new ScriptExecutorService(); + service = new CustomWidgetsService( + mockPrisma as any, + mockDataSourcesService as any, + scriptExecutor, + ); + }); + + // ─── extractField() ───────────────────────────────────────────────── + + describe('extractField()', () => { + const extract = (data: unknown, path: string) => + (service as any).extractField(data, path); + + it('should extract a simple top-level field', () => { + expect(extract({ price: 100 }, 'price')).toBe(100); + }); + + it('should extract a nested field with dot notation', () => { + expect(extract({ a: { b: 1 } }, 'a.b')).toBe(1); + }); + + it('should extract a field from an array index', () => { + const data = { items: [{ name: 'test' }] }; + expect(extract(data, 'items[0].name')).toBe('test'); + }); + + it('should return null for a missing path', () => { + expect(extract({ price: 100 }, 'missing.path')).toBeNull(); + }); + + it('should return data as-is when path is empty', () => { + const data = { price: 100 }; + expect(extract(data, '')).toEqual(data); + }); + + it('should return null/undefined when data is null', () => { + expect(extract(null, 'field')).toBeNull(); + }); + + it('should return undefined when data is undefined', () => { + expect(extract(undefined, 'field')).toBeUndefined(); + }); + + it('should handle deeply nested array access', () => { + const data = { a: { b: [{ c: [{ d: 'deep' }] }] } }; + expect(extract(data, 'a.b[0].c[0].d')).toBe('deep'); + }); + + it('should return null when array index is out of bounds', () => { + const data = { items: [{ name: 'only' }] }; + expect(extract(data, 'items[5].name')).toBeNull(); + }); + }); + + // ─── renderValue() ────────────────────────────────────────────────── + + describe('renderValue()', () => { + const renderValue = (config: Record, data: unknown) => + (service as any).renderValue(config, data); + + it('should render a string value with prefix and suffix', () => { + const config = { field: 'price', prefix: '$', suffix: ' USD' }; + expect(renderValue(config, { price: 42 })).toBe('$42 USD'); + }); + + it('should JSON.stringify an object value', () => { + const config = { field: 'meta', prefix: '', suffix: '' }; + const data = { meta: { a: 1 } }; + expect(renderValue(config, data)).toBe('{"a":1}'); + }); + + it('should return { label, value } when label is set', () => { + const config = { field: 'temp', prefix: '', suffix: 'C', label: 'Temperature' }; + const result = renderValue(config, { temp: 23 }); + expect(result).toEqual({ label: 'Temperature', value: '23C' }); + }); + + it('should return formatted string when label is empty', () => { + const config = { field: 'count', prefix: '', suffix: '' }; + expect(renderValue(config, { count: 7 })).toBe('7'); + }); + + it('should show empty string for null value', () => { + const config = { field: 'missing', prefix: '(', suffix: ')' }; + expect(renderValue(config, {})).toBe('()'); + }); + + it('should handle missing prefix/suffix gracefully', () => { + const config = { field: 'val' }; + expect(renderValue(config, { val: 'hello' })).toBe('hello'); + }); + }); + + // ─── renderList() ─────────────────────────────────────────────────── + + describe('renderList()', () => { + const renderList = (config: Record, data: unknown) => + (service as any).renderList(config, data); + + it('should render list from arrayPath with itemField', () => { + const config = { arrayPath: 'rates', itemField: 'mid', maxItems: 5, listStyle: 'bullet' }; + const data = { rates: [{ mid: 4.25 }, { mid: 3.80 }] }; + const result = renderList(config, data); + expect(result).toEqual(['• 4.25', '• 3.8']); + }); + + it('should use root array data when no arrayPath', () => { + const config = { itemField: '', maxItems: 3, listStyle: 'bullet' }; + const data = ['alpha', 'beta', 'gamma']; + const result = renderList(config, data); + expect(result).toEqual(['• alpha', '• beta', '• gamma']); + }); + + it('should fall back to data.items for RSS feed structure', () => { + const config = { itemField: 'title', maxItems: 2, listStyle: 'none' }; + const data = { items: [{ title: 'Article 1' }, { title: 'Article 2' }, { title: 'Article 3' }] }; + const result = renderList(config, data); + expect(result).toEqual(['Article 1', 'Article 2']); + }); + + it('should apply different list styles', () => { + const data = ['a', 'b', 'c']; + expect(renderList({ maxItems: 3, listStyle: 'number' }, data)).toEqual([ + '1. a', '2. b', '3. c', + ]); + expect(renderList({ maxItems: 3, listStyle: 'dash' }, data)).toEqual([ + '- a', '- b', '- c', + ]); + expect(renderList({ maxItems: 3, listStyle: 'none' }, data)).toEqual([ + 'a', 'b', 'c', + ]); + expect(renderList({ maxItems: 3, listStyle: 'bullet' }, data)).toEqual([ + '• a', '• b', '• c', + ]); + }); + + it('should cap results with maxItems', () => { + const data = [1, 2, 3, 4, 5, 6, 7]; + const result = renderList({ maxItems: 3, listStyle: 'none' }, data); + expect(result).toHaveLength(3); + }); + + it('should JSON.stringify objects when no itemField is given', () => { + const config = { maxItems: 1, listStyle: 'none' }; + const data = [{ a: 1 }]; + expect(renderList(config, data)).toEqual(['{"a":1}']); + }); + + it('should return empty array when arrayPath resolves to non-array', () => { + const config = { arrayPath: 'notAnArray', maxItems: 5, listStyle: 'bullet' }; + expect(renderList(config, { notAnArray: 'string' })).toEqual([]); + }); + }); + + // ─── renderScript() ───────────────────────────────────────────────── + + describe('renderScript()', () => { + const renderScript = ( + config: Record, + template: string | null, + data: unknown, + ) => (service as any).renderScript(config, template, data); + + it('should render value mode with prefix and suffix', () => { + const config = { + scriptCode: 'return $.price * 1000;', + scriptOutputMode: 'value', + prefix: '$', + suffix: ' PLN', + }; + expect(renderScript(config, null, { price: 4.25 })).toBe('$4250 PLN'); + }); + + it('should render template mode with {{variables}}', () => { + const config = { + scriptCode: 'var greeting = "Hello"; var name = $.user;', + scriptOutputMode: 'template', + }; + const template = '{{greeting}}, {{name}}!'; + expect(renderScript(config, template, { user: 'World' })).toBe('Hello, World!'); + }); + + it('should return error string on script failure', () => { + const config = { + scriptCode: 'return $.foo.bar.baz;', + scriptOutputMode: 'value', + }; + const result = renderScript(config, null, {}); + expect(result).toContain('Error:'); + }); + + it('should return "No script defined" when scriptCode is empty', () => { + const config = { scriptCode: '', scriptOutputMode: 'value' }; + expect(renderScript(config, null, {})).toBe('No script defined'); + }); + + it('should keep unmatched {{placeholders}} in template mode', () => { + const config = { + scriptCode: 'var x = 1;', + scriptOutputMode: 'template', + }; + const template = '{{x}} and {{missing}}'; + expect(renderScript(config, template, {})).toBe('1 and {{missing}}'); + }); + + it('should default to value mode when scriptOutputMode is not set', () => { + const config = { scriptCode: 'return 42;' }; + expect(renderScript(config, null, {})).toBe('42'); + }); + }); + + // ─── renderGrid() ─────────────────────────────────────────────────── + + describe('renderGrid()', () => { + const renderGrid = (config: Record, data: unknown) => + (service as any).renderGrid(config, data); + + it('should render a grid with field extraction', () => { + const config = { + gridCols: 2, + gridRows: 1, + gridGap: 4, + gridCells: { + '0-0': { field: 'price', prefix: '$', suffix: '', fieldType: 'number' }, + '0-1': { field: 'name', prefix: '', suffix: '', fieldType: 'text' }, + }, + }; + const data = { price: 99, name: 'Widget' }; + const result = renderGrid(config, data); + + expect(result.type).toBe('grid'); + expect(result.gridCols).toBe(2); + expect(result.gridRows).toBe(1); + expect(result.gridGap).toBe(4); + expect(result.cells).toHaveLength(2); + expect(result.cells[0].formattedValue).toBe('$99'); + expect(result.cells[1].formattedValue).toBe('Widget'); + }); + + it('should render a grid cell with script execution', () => { + const config = { + gridCols: 1, + gridRows: 1, + gridCells: { + '0-0': { field: '', useScript: true, script: 'return $.a + $.b;', fieldType: 'number' }, + }, + }; + const result = renderGrid(config, { a: 10, b: 20 }); + expect(result.cells).toHaveLength(1); + expect(result.cells[0].formattedValue).toBe('30'); + expect(result.cells[0].useScript).toBe(true); + }); + + it('should skip empty/missing cells', () => { + const config = { + gridCols: 2, + gridRows: 2, + gridCells: { + '0-0': { field: 'val', fieldType: 'text' }, + // 0-1, 1-0, 1-1 are missing + }, + }; + const result = renderGrid(config, { val: 'only' }); + expect(result.cells).toHaveLength(1); + expect(result.cells[0].row).toBe(0); + expect(result.cells[0].col).toBe(0); + }); + + it('should handle script error in grid cell gracefully', () => { + const config = { + gridCols: 1, + gridRows: 1, + gridCells: { + '0-0': { field: '', useScript: true, script: 'return $.x.y.z;', fieldType: 'text' }, + }, + }; + const result = renderGrid(config, {}); + expect(result.cells[0].formattedValue).toContain('Error:'); + expect(result.cells[0].value).toBeNull(); + }); + + it('should use default grid dimensions when not specified', () => { + const result = renderGrid({ gridCells: {} }, {}); + expect(result.gridCols).toBe(2); + expect(result.gridRows).toBe(2); + expect(result.gridGap).toBe(8); + expect(result.cells).toHaveLength(0); + }); + + it('should include alignment settings on cells', () => { + const config = { + gridCols: 1, + gridRows: 1, + gridCells: { + '0-0': { field: 'x', fieldType: 'text', align: 'right', verticalAlign: 'top' }, + }, + }; + const result = renderGrid(config, { x: 'hi' }); + expect(result.cells[0].align).toBe('right'); + expect(result.cells[0].verticalAlign).toBe('top'); + }); + }); + + // ─── renderContent() ──────────────────────────────────────────────── + + describe('renderContent()', () => { + const renderContent = ( + displayType: string, + template: string | null, + config: Record, + data: unknown, + ) => (service as any).renderContent(displayType, template, config, data); + + it('should dispatch to renderValue for "value" type', () => { + const result = renderContent('value', null, { field: 'x' }, { x: 'ok' }); + expect(result).toBe('ok'); + }); + + it('should dispatch to renderList for "list" type', () => { + const result = renderContent('list', null, { maxItems: 2, listStyle: 'none' }, ['a', 'b']); + expect(result).toEqual(['a', 'b']); + }); + + it('should dispatch to renderScript for "script" type', () => { + const result = renderContent( + 'script', + null, + { scriptCode: 'return 1;', scriptOutputMode: 'value' }, + {}, + ); + expect(result).toBe('1'); + }); + + it('should dispatch to renderGrid for "grid" type', () => { + const result = renderContent('grid', null, { gridCells: {} }, {}); + expect(result.type).toBe('grid'); + }); + + it('should stringify data for unknown display type', () => { + const result = renderContent('unknown', null, {}, 'raw'); + expect(result).toBe('raw'); + }); + }); + + // ─── CRUD / Integration tests ─────────────────────────────────────── + + describe('create()', () => { + it('should throw BadRequestException if data source does not exist', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue(null); + + await expect( + service.create({ + name: 'Test', + dataSourceId: 999, + displayType: 'value', + template: null, + config: {}, + } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should create widget when data source exists', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue({ id: 1 }); + const widget = { id: 1, name: 'Test', dataSource: { id: 1 } }; + mockPrisma.customWidget.create.mockResolvedValue(widget); + + const result = await service.create({ + name: 'Test', + dataSourceId: 1, + displayType: 'value', + template: null, + config: {}, + } as any); + + expect(result).toEqual(widget); + expect(mockPrisma.customWidget.create.calls).toHaveLength(1); + }); + }); + + describe('findOne()', () => { + it('should throw NotFoundException when widget is not found', async () => { + mockPrisma.customWidget.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + + it('should return widget when found', async () => { + const widget = { id: 1, name: 'W1' }; + mockPrisma.customWidget.findUnique.mockResolvedValue(widget); + const result = await service.findOne(1); + expect(result).toEqual(widget); + }); + }); + + describe('remove()', () => { + it('should throw NotFoundException when widget does not exist', async () => { + mockPrisma.customWidget.findUnique.mockResolvedValue(null); + await expect(service.remove(42)).rejects.toThrow(NotFoundException); + }); + + it('should delete and return success message', async () => { + mockPrisma.customWidget.findUnique.mockResolvedValue({ id: 1, name: 'W' }); + mockPrisma.customWidget.delete.mockResolvedValue({}); + + const result = await service.remove(1); + expect(result).toEqual({ message: 'Custom widget deleted successfully' }); + expect(mockPrisma.customWidget.delete.calls).toHaveLength(1); + }); + }); + + describe('update()', () => { + it('should throw NotFoundException when widget does not exist', async () => { + mockPrisma.customWidget.findUnique.mockResolvedValue(null); + await expect(service.update(99, { name: 'New' } as any)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when changing to non-existent data source', async () => { + mockPrisma.customWidget.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.dataSource.findUnique.mockResolvedValue(null); + + await expect( + service.update(1, { dataSourceId: 999 } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should update widget successfully', async () => { + const existing = { id: 1, name: 'Old' }; + const updated = { id: 1, name: 'New', dataSource: { id: 1 } }; + mockPrisma.customWidget.findUnique.mockResolvedValue(existing); + mockPrisma.customWidget.update.mockResolvedValue(updated); + + const result = await service.update(1, { name: 'New' } as any); + expect(result).toEqual(updated); + }); + }); + + describe('getAsWidgetTemplates()', () => { + it('should map custom widgets to templates with offset IDs', async () => { + const widgets = [ + { + id: 3, + name: 'Currency', + description: 'Shows currency rates', + displayType: 'value', + template: null, + config: { field: 'rate' }, + minWidth: 120, + minHeight: 60, + dataSource: { id: 1, name: 'NBP API', type: 'json', isActive: true }, + }, + { + id: 7, + name: 'News', + description: null, + displayType: 'list', + template: null, + config: {}, + minWidth: 200, + minHeight: 100, + dataSource: { id: 2, name: 'RSS Feed', type: 'rss', isActive: true }, + }, + ]; + mockPrisma.customWidget.findMany.mockResolvedValue(widgets); + + const result = await service.getAsWidgetTemplates(); + + expect(result).toHaveLength(2); + + // First widget + expect(result[0].id).toBe(CUSTOM_WIDGET_TEMPLATE_OFFSET + 3); + expect(result[0].name).toBe('custom-3'); + expect(result[0].label).toBe('Currency'); + expect(result[0].description).toBe('Shows currency rates'); + expect(result[0].category).toBe('custom'); + expect(result[0].minWidth).toBe(120); + expect(result[0].minHeight).toBe(60); + expect(result[0].defaultConfig.customWidgetId).toBe(3); + expect(result[0].defaultConfig.displayType).toBe('value'); + expect(result[0].defaultConfig.field).toBe('rate'); + expect(result[0].defaultConfig.fontSize).toBe(24); + + // Second widget - null description uses data source name fallback + expect(result[1].id).toBe(CUSTOM_WIDGET_TEMPLATE_OFFSET + 7); + expect(result[1].description).toBe('Custom widget using RSS Feed'); + }); + + it('should return empty array when no custom widgets exist', async () => { + mockPrisma.customWidget.findMany.mockResolvedValue([]); + const result = await service.getAsWidgetTemplates(); + expect(result).toEqual([]); + }); + }); + + describe('getWithData()', () => { + it('should throw NotFoundException when widget not found', async () => { + mockPrisma.customWidget.findUnique.mockResolvedValue(null); + await expect(service.getWithData(999)).rejects.toThrow(NotFoundException); + }); + + it('should return widget with rendered content', async () => { + const widget = { + id: 1, + name: 'Test', + dataSourceId: 5, + displayType: 'value', + template: null, + config: { field: 'price', prefix: '$', suffix: '' }, + dataSource: { id: 5 }, + }; + mockPrisma.customWidget.findUnique.mockResolvedValue(widget); + mockDataSourcesService.getCachedData.mockResolvedValue({ price: 100 }); + + const result = await service.getWithData(1); + + expect(result.widget).toEqual(widget); + expect(result.data).toEqual({ price: 100 }); + expect(result.renderedContent).toBe('$100'); + }); + }); +}); diff --git a/backend/src/custom-widgets/custom-widgets.service.ts b/backend/src/custom-widgets/custom-widgets.service.ts new file mode 100644 index 0000000..c69c05a --- /dev/null +++ b/backend/src/custom-widgets/custom-widgets.service.ts @@ -0,0 +1,551 @@ +import { + Injectable, + NotFoundException, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { DataSourcesService } from '../data-sources/data-sources.service'; +import { ScriptExecutorService } from './services/script-executor.service'; +import { CreateCustomWidgetDto } from './dto/create-custom-widget.dto'; +import { UpdateCustomWidgetDto } from './dto/update-custom-widget.dto'; +import { wrapListResponse, wrapPaginatedResponse } from '../common/utils/response.util'; +import { CUSTOM_WIDGET_TEMPLATE_OFFSET } from '../common/constants/widget.constants'; + +@Injectable() +export class CustomWidgetsService { + private readonly logger = new Logger(CustomWidgetsService.name); + + constructor( + private prisma: PrismaService, + private dataSourcesService: DataSourcesService, + private scriptExecutor: ScriptExecutorService, + ) {} + + /** + * Create a new custom widget + */ + async create(createCustomWidgetDto: CreateCustomWidgetDto) { + // Verify data source exists + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id: createCustomWidgetDto.dataSourceId }, + }); + + if (!dataSource) { + throw new BadRequestException('Data source not found'); + } + + const customWidget = await this.prisma.customWidget.create({ + data: { + name: createCustomWidgetDto.name, + description: createCustomWidgetDto.description, + dataSourceId: createCustomWidgetDto.dataSourceId, + displayType: createCustomWidgetDto.displayType, + template: createCustomWidgetDto.template, + config: (createCustomWidgetDto.config || {}) as object, + minWidth: createCustomWidgetDto.minWidth || 100, + minHeight: createCustomWidgetDto.minHeight || 50, + }, + include: { + dataSource: true, + }, + }); + + this.logger.log(`Custom widget created: ${customWidget.name}`); + + return customWidget; + } + + /** + * Find all custom widgets with pagination + */ + async findAll(page = 1, limit = 20) { + const skip = (page - 1) * limit; + + const [customWidgets, total] = await Promise.all([ + this.prisma.customWidget.findMany({ + include: { + dataSource: { + select: { + id: true, + name: true, + type: true, + isActive: true, + lastFetchedAt: true, + lastError: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + }), + this.prisma.customWidget.count(), + ]); + + return wrapPaginatedResponse(customWidgets, total, page, limit); + } + + /** + * Find one custom widget by ID + */ + async findOne(id: number) { + const customWidget = await this.prisma.customWidget.findUnique({ + where: { id }, + include: { + dataSource: true, + }, + }); + + if (!customWidget) { + throw new NotFoundException('Custom widget not found'); + } + + return customWidget; + } + + /** + * Update custom widget + */ + async update(id: number, updateCustomWidgetDto: UpdateCustomWidgetDto) { + const customWidget = await this.prisma.customWidget.findUnique({ + where: { id }, + }); + + if (!customWidget) { + throw new NotFoundException('Custom widget not found'); + } + + // Verify new data source exists if changing + if (updateCustomWidgetDto.dataSourceId) { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id: updateCustomWidgetDto.dataSourceId }, + }); + + if (!dataSource) { + throw new BadRequestException('Data source not found'); + } + } + + const updatedWidget = await this.prisma.customWidget.update({ + where: { id }, + data: { + name: updateCustomWidgetDto.name, + description: updateCustomWidgetDto.description, + dataSourceId: updateCustomWidgetDto.dataSourceId, + displayType: updateCustomWidgetDto.displayType, + template: updateCustomWidgetDto.template, + config: updateCustomWidgetDto.config as object | undefined, + minWidth: updateCustomWidgetDto.minWidth, + minHeight: updateCustomWidgetDto.minHeight, + }, + include: { + dataSource: true, + }, + }); + + this.logger.log(`Custom widget updated: ${updatedWidget.name}`); + + return updatedWidget; + } + + /** + * Delete custom widget + */ + async remove(id: number) { + const customWidget = await this.prisma.customWidget.findUnique({ + where: { id }, + }); + + if (!customWidget) { + throw new NotFoundException('Custom widget not found'); + } + + await this.prisma.customWidget.delete({ + where: { id }, + }); + + this.logger.log(`Custom widget deleted: ${customWidget.name}`); + + return { message: 'Custom widget deleted successfully' }; + } + + /** + * Get widget with rendered data + * Fetches latest data from source and applies template + */ + async getWithData(id: number) { + const customWidget = await this.prisma.customWidget.findUnique({ + where: { id }, + include: { + dataSource: true, + }, + }); + + if (!customWidget) { + throw new NotFoundException('Custom widget not found'); + } + + // Get cached or fresh data from data source + const data = await this.dataSourcesService.getCachedData( + customWidget.dataSourceId, + ); + + // Render the data based on display type + const renderedContent = this.renderContent( + customWidget.displayType, + customWidget.template, + customWidget.config as Record, + data, + ); + + return { + widget: customWidget, + data, + renderedContent, + }; + } + + /** + * Render content based on display type + */ + private renderContent( + displayType: string, + template: string | null, + config: Record, + data: unknown, + ): string | string[] | Record { + switch (displayType) { + case 'value': + return this.renderValue(config, data); + + case 'list': + return this.renderList(config, data); + + case 'script': + return this.renderScript(config, template, data); + + case 'grid': + return this.renderGrid(config, data); + + default: + return String(data); + } + } + + /** + * Render single value display + * Config: { field: "price", prefix: "$", suffix: "", label: "Price" } + */ + private renderValue( + config: Record, + data: unknown, + ): string | Record { + const field = config.field as string; + const prefix = (config.prefix as string) || ''; + const suffix = (config.suffix as string) || ''; + const label = (config.label as string) || ''; + + const rawValue = this.extractField(data, field); + + // Format the value + let formattedValue: string; + if (rawValue !== null && typeof rawValue === 'object') { + formattedValue = `${prefix}${JSON.stringify(rawValue)}${suffix}`; + } else { + formattedValue = `${prefix}${rawValue ?? ''}${suffix}`; + } + + // If label is set, return object with label and value + if (label) { + return { + label, + value: formattedValue, + }; + } + + return formattedValue; + } + + /** + * Render list display + * Config: { arrayPath: "rates", itemField: "mid", maxItems: 5, listStyle: "bullet" } + */ + private renderList( + config: Record, + data: unknown, + ): string[] { + const arrayPath = config.arrayPath as string; + const itemField = config.itemField as string; + const maxItems = (config.maxItems as number) || 5; + const listStyle = (config.listStyle as string) || 'bullet'; + + // Get array from data using path + let arrayData: unknown[] = []; + + if (arrayPath) { + // Use specified array path + const extracted = this.extractField(data, arrayPath); + if (Array.isArray(extracted)) { + arrayData = extracted; + } + } else if (Array.isArray(data)) { + // Root is an array + arrayData = data; + } else if ( + typeof data === 'object' && + data !== null && + 'items' in data && + Array.isArray((data as Record).items) + ) { + // RSS feed structure fallback + arrayData = (data as Record).items as unknown[]; + } + + // Extract items and apply formatting + return arrayData.slice(0, maxItems).map((item, index) => { + // Get the value to display + let value: string; + if (itemField && typeof item === 'object' && item !== null) { + const fieldValue = (item as Record)[itemField]; + value = fieldValue !== undefined ? String(fieldValue) : JSON.stringify(item); + } else if (typeof item === 'object' && item !== null) { + value = JSON.stringify(item); + } else { + value = String(item); + } + + // Apply list style prefix + switch (listStyle) { + case 'number': + return `${index + 1}. ${value}`; + case 'dash': + return `- ${value}`; + case 'none': + return value; + default: // bullet + return `• ${value}`; + } + }); + } + + /** + * Render script display type + * Executes user JavaScript code to transform data + * Config: { scriptCode: "...", scriptOutputMode: "value" | "template", prefix?, suffix? } + */ + private renderScript( + config: Record, + template: string | null, + data: unknown, + ): string { + const code = config.scriptCode as string; + const outputMode = (config.scriptOutputMode as string) || 'value'; + + if (!code) { + return 'No script defined'; + } + + if (outputMode === 'value') { + const result = this.scriptExecutor.execute(code, data, 'value'); + if (!result.success) { + this.logger.warn(`Script error: ${result.error}`); + return `Error: ${result.error}`; + } + const prefix = (config.prefix as string) || ''; + const suffix = (config.suffix as string) || ''; + return `${prefix}${result.value ?? ''}${suffix}`; + } else { + // Template mode: execute script to get variables, then apply template + const result = this.scriptExecutor.execute(code, data, 'template'); + if (!result.success) { + this.logger.warn(`Script error: ${result.error}`); + return `Error: ${result.error}`; + } + // Replace {{varName}} with variable values + const templateStr = template || ''; + return templateStr.replace(/\{\{([^}]+)\}\}/g, (_, varName) => { + const value = result.variables?.[varName.trim()]; + return value !== undefined ? String(value) : `{{${varName}}}`; + }); + } + } + + /** + * Render grid display type + * Displays multiple values in a configurable grid layout + * Config: { gridCols: 2, gridRows: 2, gridGap: 8, gridCells: { "0-0": { field, label, prefix, suffix, fieldType, useScript, script } } } + */ + private renderGrid( + config: Record, + data: unknown, + ): Record { + const gridCols = (config.gridCols as number) || 2; + const gridRows = (config.gridRows as number) || 2; + const gridGap = (config.gridGap as number) || 8; + const gridCells = (config.gridCells as Record>) || {}; + + // Build array of cell data + const cells: Array<{ + row: number; + col: number; + field: string; + fieldType: string; + label?: string; + prefix?: string; + suffix?: string; + value: unknown; + formattedValue: string; + useScript?: boolean; + align?: string; + verticalAlign?: string; + }> = []; + + for (let row = 0; row < gridRows; row++) { + for (let col = 0; col < gridCols; col++) { + const cellKey = `${row}-${col}`; + const cellConfig = gridCells[cellKey]; + + if (cellConfig && (cellConfig.field || cellConfig.useScript)) { + const field = (cellConfig.field as string) || ''; + const useScript = cellConfig.useScript as boolean; + const script = cellConfig.script as string; + + let rawValue: unknown; + let formattedValue: string; + + if (useScript && script) { + // Execute script for this cell + const result = this.scriptExecutor.execute(script, data, 'value'); + if (result.success) { + rawValue = result.value; + formattedValue = String(result.value ?? ''); + } else { + this.logger.warn(`Grid cell script error: ${result.error}`); + rawValue = null; + formattedValue = `Error: ${result.error}`; + } + } else { + // Standard field extraction with prefix/suffix + rawValue = this.extractField(data, field); + const prefix = (cellConfig.prefix as string) || ''; + const suffix = (cellConfig.suffix as string) || ''; + + if (rawValue !== null && typeof rawValue === 'object') { + formattedValue = `${prefix}${JSON.stringify(rawValue)}${suffix}`; + } else { + formattedValue = `${prefix}${rawValue ?? ''}${suffix}`; + } + } + + cells.push({ + row, + col, + field, + fieldType: (cellConfig.fieldType as string) || 'text', + label: cellConfig.label as string | undefined, + prefix: cellConfig.prefix as string | undefined, + suffix: cellConfig.suffix as string | undefined, + value: rawValue, + formattedValue, + useScript, + align: (cellConfig.align as string) || 'center', + verticalAlign: (cellConfig.verticalAlign as string) || 'middle', + }); + } + } + } + + return { + type: 'grid', + gridCols, + gridRows, + gridGap, + cells, + }; + } + + /** + * Extract field value from data using dot notation + * Supports: "field", "nested.field", "array[0].field" + */ + private extractField(data: unknown, path: string): unknown { + if (!path || data === null || data === undefined) { + return data; + } + + const parts = path.split(/\.|(\[\d+\])/).filter(Boolean); + let current: unknown = data; + + for (const part of parts) { + if (current === null || current === undefined) { + return null; + } + + // Handle array index: [0] + const indexMatch = part.match(/\[(\d+)\]/); + if (indexMatch) { + const index = parseInt(indexMatch[1], 10); + if (Array.isArray(current)) { + current = current[index]; + } else { + return null; + } + } else if (typeof current === 'object') { + current = (current as Record)[part]; + } else { + return null; + } + } + + return current; + } + + /** + * Get all custom widgets as widget templates for the screen designer + * + * Custom widget templates use IDs offset by CUSTOM_WIDGET_TEMPLATE_OFFSET + * to avoid collisions with built-in widget template IDs. + */ + async getAsWidgetTemplates() { + const customWidgets = await this.prisma.customWidget.findMany({ + include: { + dataSource: { + select: { + id: true, + name: true, + type: true, + isActive: true, + }, + }, + }, + }); + + return customWidgets.map((widget) => ({ + // Use offset ID to prevent collision with built-in template IDs + id: CUSTOM_WIDGET_TEMPLATE_OFFSET + widget.id, + name: `custom-${widget.id}`, + label: widget.name, + description: widget.description || `Custom widget using ${widget.dataSource.name}`, + category: 'custom', + defaultConfig: { + // Store the actual widget ID for fetching preview data + customWidgetId: widget.id, + displayType: widget.displayType, + template: widget.template, + // Default styling settings - ensures consistent rendering + fontSize: 24, + fontFamily: 'sans-serif', + fontWeight: 'normal', + textAlign: 'center', + verticalAlign: 'middle', + color: '#000000', + ...(widget.config as object), + }, + minWidth: widget.minWidth, + minHeight: widget.minHeight, + })); + } +} diff --git a/backend/src/custom-widgets/dto/create-custom-widget.dto.ts b/backend/src/custom-widgets/dto/create-custom-widget.dto.ts new file mode 100644 index 0000000..2fb0121 --- /dev/null +++ b/backend/src/custom-widgets/dto/create-custom-widget.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsInt, + Min, + MaxLength, +} from 'class-validator'; + +export class CreateCustomWidgetDto { + @ApiProperty({ example: 'Bitcoin Price Widget' }) + @IsString() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ example: 'Displays current BTC price' }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiProperty({ + example: 1, + description: 'ID of the data source to use', + }) + @IsInt() + dataSourceId: number; + + @ApiProperty({ + example: 'value', + enum: ['value', 'list', 'script', 'grid'], + description: 'How to display the data', + }) + @IsString() + @IsIn(['value', 'list', 'script', 'grid']) + displayType: string; + + @ApiPropertyOptional({ + example: '{{name}}: ${{price}}', + description: 'Template string for template display type', + }) + @IsOptional() + @IsString() + @MaxLength(5000) + template?: string; + + @ApiPropertyOptional({ + example: { fontSize: 24, textAlign: 'center' }, + description: 'Layout and style configuration', + }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional({ + example: 150, + default: 100, + description: 'Minimum widget width in pixels', + }) + @IsOptional() + @IsInt() + @Min(50) + minWidth?: number; + + @ApiPropertyOptional({ + example: 80, + default: 50, + description: 'Minimum widget height in pixels', + }) + @IsOptional() + @IsInt() + @Min(30) + minHeight?: number; +} diff --git a/backend/src/custom-widgets/dto/update-custom-widget.dto.ts b/backend/src/custom-widgets/dto/update-custom-widget.dto.ts new file mode 100644 index 0000000..e6411b9 --- /dev/null +++ b/backend/src/custom-widgets/dto/update-custom-widget.dto.ts @@ -0,0 +1,77 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsInt, + Min, + MaxLength, +} from 'class-validator'; + +export class UpdateCustomWidgetDto { + @ApiPropertyOptional({ example: 'Bitcoin Price Widget' }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ example: 'Displays current BTC price' }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ + example: 1, + description: 'ID of the data source to use', + }) + @IsOptional() + @IsInt() + dataSourceId?: number; + + @ApiPropertyOptional({ + example: 'value', + enum: ['value', 'list', 'script', 'grid'], + description: 'How to display the data', + }) + @IsOptional() + @IsString() + @IsIn(['value', 'list', 'script', 'grid']) + displayType?: string; + + @ApiPropertyOptional({ + example: '{{name}}: ${{price}}', + description: 'Template string for template display type', + }) + @IsOptional() + @IsString() + @MaxLength(5000) + template?: string; + + @ApiPropertyOptional({ + example: { fontSize: 24, textAlign: 'center' }, + description: 'Layout and style configuration', + }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional({ + example: 150, + description: 'Minimum widget width in pixels', + }) + @IsOptional() + @IsInt() + @Min(50) + minWidth?: number; + + @ApiPropertyOptional({ + example: 80, + description: 'Minimum widget height in pixels', + }) + @IsOptional() + @IsInt() + @Min(30) + minHeight?: number; +} diff --git a/backend/src/custom-widgets/services/script-executor.service.test.ts b/backend/src/custom-widgets/services/script-executor.service.test.ts new file mode 100644 index 0000000..8abd77d --- /dev/null +++ b/backend/src/custom-widgets/services/script-executor.service.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'bun:test'; +import { ScriptExecutorService } from './script-executor.service'; + +describe('ScriptExecutorService', () => { + const executor = new ScriptExecutorService(); + + describe('value mode', () => { + it('should return simple values', () => { + const result = executor.execute('return 42;', {}, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(42); + }); + + it('should return string values', () => { + const result = executor.execute('return "hello";', {}, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe('hello'); + }); + + it('should access data via $ syntax', () => { + const result = executor.execute('return $.price;', { price: 100 }, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(100); + }); + + it('should access nested data', () => { + const data = { rates: [{ mid: 4.25 }] }; + const result = executor.execute('return $.rates[0].mid;', data, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(4.25); + }); + + it('should support arithmetic expressions', () => { + const result = executor.execute('return $.price * 1000;', { price: 3.5 }, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(3500); + }); + + it('should support Math functions', () => { + const result = executor.execute('return Math.floor($.value);', { value: 3.7 }, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(3); + }); + + it('should support JSON operations', () => { + const result = executor.execute( + 'var obj = JSON.parse(JSON.stringify($)); return obj.x;', + { x: 42 }, + 'value', + ); + expect(result.success).toBe(true); + expect(result.value).toBe(42); + }); + + it('should support parseInt/parseFloat', () => { + const result = executor.execute('return parseInt("42") + parseFloat("0.5");', {}, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(42.5); + }); + + it('should support string operations', () => { + const result = executor.execute( + 'return $.name.toUpperCase();', + { name: 'hello' }, + 'value', + ); + expect(result.success).toBe(true); + expect(result.value).toBe('HELLO'); + }); + + it('should support array operations', () => { + const result = executor.execute( + 'return $.items.length;', + { items: [1, 2, 3] }, + 'value', + ); + expect(result.success).toBe(true); + expect(result.value).toBe(3); + }); + }); + + describe('template mode', () => { + it('should extract declared variables', () => { + const code = 'var greeting = "hello"; var count = 5;'; + const result = executor.execute(code, {}, 'template'); + expect(result.success).toBe(true); + expect(result.variables).toEqual({ greeting: 'hello', count: 5 }); + }); + + it('should access $ data in variables', () => { + const code = 'var total = $.price * $.quantity;'; + const result = executor.execute(code, { price: 10, quantity: 3 }, 'template'); + expect(result.success).toBe(true); + expect(result.variables?.total).toBe(30); + }); + }); + + describe('error handling', () => { + it('should return error for syntax errors', () => { + const result = executor.execute('return {{{;', {}, 'value'); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should return error for runtime errors', () => { + const result = executor.execute('return $.foo.bar.baz;', {}, 'value'); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('sandbox security', () => { + it('should not have access to process', () => { + const result = executor.execute('return typeof _process;', {}, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe('undefined'); + }); + + it('should not have access to require', () => { + const result = executor.execute('return typeof _require;', {}, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe('undefined'); + }); + + it('should timeout on infinite loops', () => { + const result = executor.execute('while(true) {}', {}, 'value'); + expect(result.success).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('should block constructor keyword in scripts', () => { + const result = executor.execute( + 'return parseInt.constructor("return 1")()', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block __proto__ keyword in scripts', () => { + const result = executor.execute( + 'return $.__proto__;', + { a: 1 }, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block prototype keyword in scripts', () => { + const result = executor.execute( + 'return Object.prototype;', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block Function keyword in scripts', () => { + const result = executor.execute( + 'return Function("return 1")()', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block eval keyword in scripts', () => { + const result = executor.execute( + 'return eval("1+1")', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block process keyword in scripts', () => { + const result = executor.execute( + 'return process.env;', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block require keyword in scripts', () => { + const result = executor.execute( + 'return require("fs");', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block import keyword in scripts', () => { + const result = executor.execute( + 'var x = import("fs");', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should block globalThis keyword in scripts', () => { + const result = executor.execute( + 'return globalThis;', + {}, + 'value', + ); + expect(result.success).toBe(false); + expect(result.error).toContain('forbidden keyword'); + }); + + it('should reject oversized scripts', () => { + const code = 'return ' + '"x"'.repeat(5000) + ';'; + const result = executor.execute(code, {}, 'value'); + expect(result.success).toBe(false); + expect(result.error).toContain('Script too large'); + }); + + it('should not have Function in context', () => { + // Use a variable name that doesn't trigger keyword blocking + const result = executor.execute( + 'return typeof _Fn;', + {}, + 'value', + ); + expect(result.success).toBe(true); + expect(result.value).toBe('undefined'); + }); + + it('should not have eval in context', () => { + // Use a variable name that doesn't trigger keyword blocking + const result = executor.execute( + 'return typeof _ev;', + {}, + 'value', + ); + expect(result.success).toBe(true); + expect(result.value).toBe('undefined'); + }); + + it('should handle null data safely', () => { + const result = executor.execute('return $ === null;', null, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(true); + }); + + it('should handle undefined data safely', () => { + const result = executor.execute('return $ === null;', undefined, 'value'); + expect(result.success).toBe(true); + expect(result.value).toBe(true); + }); + }); +}); diff --git a/backend/src/custom-widgets/services/script-executor.service.ts b/backend/src/custom-widgets/services/script-executor.service.ts new file mode 100644 index 0000000..f925cb4 --- /dev/null +++ b/backend/src/custom-widgets/services/script-executor.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as vm from 'vm'; + +export interface ScriptResult { + success: boolean; + value?: unknown; + variables?: Record; + error?: string; +} + +@Injectable() +export class ScriptExecutorService { + private readonly logger = new Logger(ScriptExecutorService.name); + private readonly TIMEOUT_MS = 1000; + private readonly MAX_SCRIPT_LENGTH = 10_000; + + /** + * Blocked keywords that could be used to escape the VM sandbox. + * Checked via word-boundary regex to avoid false positives in data strings. + */ + private readonly BLOCKED_PATTERNS: readonly RegExp[] = [ + /\bconstructor\b/, + /\b__proto__\b/, + /\bprototype\b/, + /\bFunction\b/, + /\beval\b/, + /\bimport\b/, + /\bglobalThis\b/, + /\bprocess\b/, + /\brequire\b/, + /\bProxy\b/, + /\bReflect\b/, + /\bSymbol\b/, + /\bWeakRef\b/, + /\bFinalizationRegistry\b/, + ]; + + /** + * Execute user script with $ proxy for data access + * @param code - User JavaScript code + * @param data - API data accessible via $.fieldName + * @param mode - 'value' returns script result, 'template' extracts declared variables + */ + execute( + code: string, + data: unknown, + mode: 'value' | 'template', + ): ScriptResult { + try { + // Validate script before execution + this.validateScript(code); + + // Build context with no prototype chain. + // After vm.createContext(), V8 populates the context with its own + // isolated builtins (Math, JSON, parseInt, etc.) that are safe — + // their prototype chains lead to the VM's own Function, not the host's. + const context: vm.Context = Object.create(null); + vm.createContext(context); + + // Remove dangerous VM globals that aren't needed for data transformation + context.Function = undefined; + context.eval = undefined; + context['constructor'] = undefined; + context['__proto__'] = undefined; + context.WeakRef = undefined; + context.FinalizationRegistry = undefined; + context.Proxy = undefined; + context.Reflect = undefined; + context.Symbol = undefined; + + // Inject $ data safely: serialize to JSON, parse INSIDE the VM. + // This ensures all data objects have VM-realm prototypes, not host prototypes. + // Even if an attacker traverses $.constructor, they reach the VM's Function, + // which cannot access the host's process/require. + const dataJson = JSON.stringify(data ?? null); + const initScript = new vm.Script( + `var $ = JSON.parse(${JSON.stringify(dataJson)});`, + ); + initScript.runInContext(context, { timeout: this.TIMEOUT_MS }); + + if (mode === 'value') { + return this.executeValueMode(context, code); + } else { + return this.executeTemplateMode(context, code); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Script execution failed: ${message}`); + return { success: false, error: message }; + } + } + + /** + * Validate script for dangerous patterns and size limits. + * This is defense-in-depth — the primary protection is VM-realm isolation. + */ + private validateScript(code: string): void { + if (code.length > this.MAX_SCRIPT_LENGTH) { + throw new Error( + `Script too large: ${code.length} characters (max ${this.MAX_SCRIPT_LENGTH})`, + ); + } + + for (const pattern of this.BLOCKED_PATTERNS) { + if (pattern.test(code)) { + throw new Error( + `Script contains forbidden keyword: ${pattern.source.replace(/\\b/g, '')}`, + ); + } + } + } + + /** + * Execute script in value mode - returns the result of the script + */ + private executeValueMode(context: vm.Context, code: string): ScriptResult { + // Wrap code in IIFE to capture return value + const wrappedCode = `(function() { ${code} })()`; + + try { + const script = new vm.Script(wrappedCode); + const result = script.runInContext(context, { timeout: this.TIMEOUT_MS }); + return { success: true, value: result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + } + + /** + * Execute script in template mode - extracts all declared variables + */ + private executeTemplateMode(context: vm.Context, code: string): ScriptResult { + // Extract variable names declared with var, let, or const + const variableNames = this.extractVariableNames(code); + + // Build code that executes user script and collects variables + const collectVarsCode = variableNames + .map((v) => `if (typeof ${v} !== 'undefined') __vars['${v}'] = ${v};`) + .join('\n'); + + const wrappedCode = ` + (function() { + var __vars = {}; + ${code} + ${collectVarsCode} + return __vars; + })() + `; + + try { + const script = new vm.Script(wrappedCode); + const variables = script.runInContext(context, { + timeout: this.TIMEOUT_MS, + }) as Record; + return { success: true, variables }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + } + + /** + * Extract variable names from code (var, let, const declarations) + */ + private extractVariableNames(code: string): string[] { + const varRegex = /(?:var|let|const)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g; + const names: string[] = []; + let match; + while ((match = varRegex.exec(code)) !== null) { + // Skip internal variables + if (!match[1].startsWith('__')) { + names.push(match[1]); + } + } + return [...new Set(names)]; // Remove duplicates + } +} diff --git a/backend/src/dashboard/dashboard.controller.test.ts b/backend/src/dashboard/dashboard.controller.test.ts new file mode 100644 index 0000000..7e62d03 --- /dev/null +++ b/backend/src/dashboard/dashboard.controller.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('DashboardController (e2e)', () => { + let app: INestApplication; + + const mockDashboardService = { + getStats: async () => ({ + totalDevices: 5, + onlineDevices: 2, + totalScreens: 10, + totalPlaylists: 3, + recentDevices: [], + recentScreens: [], + }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [DashboardController], + providers: [ + { provide: DashboardService, useValue: mockDashboardService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /dashboard/stats', () => { + it('should return dashboard statistics', async () => { + const response = await request(app.getHttpServer()) + .get('/dashboard/stats') + .expect(200); + + expect(response.body.data).toHaveProperty('totalDevices', 5); + expect(response.body.data).toHaveProperty('onlineDevices', 2); + expect(response.body.data).toHaveProperty('totalScreens', 10); + expect(response.body.data).toHaveProperty('totalPlaylists', 3); + }); + + it('should include recent devices and screens arrays', async () => { + const response = await request(app.getHttpServer()) + .get('/dashboard/stats') + .expect(200); + + expect(response.body.data).toHaveProperty('recentDevices'); + expect(response.body.data).toHaveProperty('recentScreens'); + expect(Array.isArray(response.body.data.recentDevices)).toBe(true); + expect(Array.isArray(response.body.data.recentScreens)).toBe(true); + }); + }); +}); diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..2a96d7a --- /dev/null +++ b/backend/src/dashboard/dashboard.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { DashboardService } from './dashboard.service'; +import { DashboardStatsDto } from './dto/dashboard-stats.dto'; + +/** + * Dashboard controller + * Provides endpoints for dashboard statistics and overview data + * + * All endpoints require authentication (JWT Bearer token) + */ +@ApiTags('dashboard') +@Controller('dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + /** + * Get dashboard statistics + * + * Returns aggregated statistics including: + * - Total device count + * - Online device count (seen in last 5 minutes) + * - Total screen count + * - Total playlist count + * - 5 most recent devices + * - 4 most recent screens + * + * This endpoint is called by the frontend Dashboard component + * to display overview statistics and recent activity. + * + * @returns DashboardStatsDto containing all statistics + */ + @Get('stats') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Get dashboard statistics', + description: 'Retrieve aggregated statistics and recent items for the dashboard view' + }) + @ApiResponse({ + status: 200, + description: 'Dashboard statistics successfully retrieved', + type: DashboardStatsDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - JWT token missing or invalid', + }) + @ApiResponse({ + status: 500, + description: 'Internal server error - Database query failed', + }) + async getStats(): Promise { + return this.dashboardService.getStats(); + } +} diff --git a/backend/src/dashboard/dashboard.module.ts b/backend/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..d17e6ee --- /dev/null +++ b/backend/src/dashboard/dashboard.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +/** + * Dashboard module + * Provides dashboard statistics and overview functionality + * + * This module: + * - Imports PrismaModule for database access + * - Exports DashboardService for potential use in other modules + * - Registers DashboardController for the /dashboard routes + */ +@Module({ + imports: [PrismaModule], + controllers: [DashboardController], + providers: [DashboardService], + exports: [DashboardService], +}) +export class DashboardModule {} diff --git a/backend/src/dashboard/dashboard.service.test.ts b/backend/src/dashboard/dashboard.service.test.ts new file mode 100644 index 0000000..fcaab4a --- /dev/null +++ b/backend/src/dashboard/dashboard.service.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { DashboardService } from './dashboard.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; + +describe('DashboardService', () => { + let service: DashboardService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new DashboardService(mockPrisma as any); + }); + + // ─── getStats ──────────────────────────────────────────────────────── + + describe('getStats()', () => { + it('should return all counts and recent items', async () => { + mockPrisma.device.count.mockImplementation((...args: any[]) => { + // First call: totalDevices (no args), second call: onlineDevices (with where) + if (args.length === 0 || !args[0]?.where) return Promise.resolve(10); + return Promise.resolve(3); + }); + mockPrisma.screenDesign.count.mockResolvedValue(5); + mockPrisma.playlist.count.mockResolvedValue(2); + + const recentDevices = [ + { id: 1, name: 'Dev1', macAddress: 'AA:BB', lastSeenAt: new Date(), createdAt: new Date(), battery: 90, wifi: -30 }, + ]; + const recentScreens = [ + { id: 1, name: 'Screen1', description: null, createdAt: new Date() }, + ]; + mockPrisma.device.findMany.mockResolvedValue(recentDevices); + mockPrisma.screenDesign.findMany.mockResolvedValue(recentScreens); + + const stats = await service.getStats(); + expect(stats.totalDevices).toBe(10); + expect(stats.onlineDevices).toBe(3); + expect(stats.totalScreens).toBe(5); + expect(stats.totalPlaylists).toBe(2); + expect(stats.recentDevices).toHaveLength(1); + expect(stats.recentDevices[0].status).toBe('online'); + expect(stats.recentScreens).toHaveLength(1); + }); + }); + + // ─── getDeviceStatus (private) ────────────────────────────────────── + + describe('getDeviceStatus()', () => { + const getStatus = (lastSeenAt: Date | null, threshold: Date) => + (service as any).getDeviceStatus(lastSeenAt, threshold); + + it('should return offline when lastSeenAt is null', () => { + const threshold = new Date(); + expect(getStatus(null, threshold)).toBe('offline'); + }); + + it('should return online when lastSeenAt is recent', () => { + const now = new Date(); + const threshold = new Date(now.getTime() - 5 * 60 * 1000); + expect(getStatus(now, threshold)).toBe('online'); + }); + + it('should return offline when lastSeenAt is older than threshold', () => { + const threshold = new Date(Date.now() - 5 * 60 * 1000); + const oldDate = new Date(Date.now() - 10 * 60 * 1000); + expect(getStatus(oldDate, threshold)).toBe('offline'); + }); + }); +}); diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..c5b561c --- /dev/null +++ b/backend/src/dashboard/dashboard.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { DashboardStatsDto } from './dto/dashboard-stats.dto'; + +/** + * Dashboard service + * Provides aggregated statistics for the dashboard view + * + * Per Node.js best practices, this service: + * - Uses async/await for database operations + * - Implements proper error handling + * - Uses structured logging for observability + * - Optimizes database queries with Prisma + */ +@Injectable() +export class DashboardService { + private readonly logger = new Logger(DashboardService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Get dashboard statistics including counts and recent items + * + * This method performs multiple database queries in parallel using Promise.all + * for optimal performance. Device online status is determined by checking + * if lastSeenAt is within the last 5 minutes. + * + * @returns DashboardStatsDto containing all dashboard statistics + */ + async getStats(): Promise { + this.logger.log('Fetching dashboard statistics'); + + try { + // Calculate the timestamp for "online" devices (last 5 minutes) + const onlineThreshold = new Date(Date.now() - 5 * 60 * 1000); + + // Execute all queries in parallel for optimal performance + const [ + totalDevices, + onlineDevices, + totalScreens, + totalPlaylists, + recentDevices, + recentScreens, + ] = await Promise.all([ + // Count total devices + this.prisma.device.count(), + + // Count online devices (seen within last 5 minutes) + this.prisma.device.count({ + where: { + lastSeenAt: { + gte: onlineThreshold, + }, + }, + }), + + // Count total screen designs + this.prisma.screenDesign.count(), + + // Count total playlists + this.prisma.playlist.count(), + + // Get 5 most recent devices with relevant fields + this.prisma.device.findMany({ + take: 5, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + name: true, + macAddress: true, + lastSeenAt: true, + createdAt: true, + battery: true, + wifi: true, + }, + }), + + // Get 4 most recent screen designs with relevant fields + this.prisma.screenDesign.findMany({ + take: 4, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + name: true, + description: true, + createdAt: true, + }, + }), + ]); + + // Map devices to include computed status field + const mappedDevices = recentDevices.map(device => ({ + ...device, + status: this.getDeviceStatus(device.lastSeenAt, onlineThreshold), + })); + + const stats: DashboardStatsDto = { + totalDevices, + onlineDevices, + totalScreens, + totalPlaylists, + recentDevices: mappedDevices, + recentScreens, + }; + + this.logger.log( + `Dashboard stats retrieved: ${totalDevices} devices (${onlineDevices} online), ` + + `${totalScreens} screens, ${totalPlaylists} playlists` + ); + + return stats; + } catch (error) { + this.logger.error('Failed to fetch dashboard statistics', error); + throw error; + } + } + + /** + * Determine device status based on lastSeenAt timestamp + * + * @param lastSeenAt - Last time device was seen + * @param onlineThreshold - Threshold date for considering device online + * @returns 'online' or 'offline' + */ + private getDeviceStatus( + lastSeenAt: Date | null, + onlineThreshold: Date + ): 'online' | 'offline' { + if (!lastSeenAt) { + return 'offline'; + } + return lastSeenAt >= onlineThreshold ? 'online' : 'offline'; + } +} diff --git a/backend/src/dashboard/dto/dashboard-stats.dto.ts b/backend/src/dashboard/dto/dashboard-stats.dto.ts new file mode 100644 index 0000000..bac333d --- /dev/null +++ b/backend/src/dashboard/dto/dashboard-stats.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * Device data for recent devices list + */ +class RecentDeviceDto { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 'Living Room Device' }) + name: string; + + @ApiProperty({ example: '00:11:22:33:44:55' }) + macAddress: string; + + @ApiProperty({ example: 'online', enum: ['online', 'offline'] }) + status: string; + + @ApiProperty({ example: '2025-12-06T10:30:00Z' }) + lastSeenAt: Date | null; + + @ApiProperty({ example: '2025-12-06T09:00:00Z' }) + createdAt: Date; +} + +/** + * Screen data for recent screens list + */ +class RecentScreenDto { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 'Welcome Screen' }) + name: string; + + @ApiProperty({ example: 'Welcome screen description', nullable: true }) + description: string | null; + + @ApiProperty({ example: '2025-12-06T09:00:00Z' }) + createdAt: Date; +} + +/** + * Dashboard statistics response DTO + * Matches the frontend DashboardStats interface + */ +export class DashboardStatsDto { + @ApiProperty({ + description: 'Total number of devices in the system', + example: 15 + }) + totalDevices: number; + + @ApiProperty({ + description: 'Number of devices currently online (seen in last 5 minutes)', + example: 12 + }) + onlineDevices: number; + + @ApiProperty({ + description: 'Total number of screens in the system', + example: 25 + }) + totalScreens: number; + + @ApiProperty({ + description: 'Total number of playlists in the system', + example: 8 + }) + totalPlaylists: number; + + @ApiProperty({ + description: 'Most recently created devices (up to 5)', + type: [RecentDeviceDto] + }) + recentDevices: RecentDeviceDto[]; + + @ApiProperty({ + description: 'Most recently created screens (up to 4)', + type: [RecentScreenDto] + }) + recentScreens: RecentScreenDto[]; +} diff --git a/backend/src/data-sources/data-sources.controller.test.ts b/backend/src/data-sources/data-sources.controller.test.ts new file mode 100644 index 0000000..ced76a6 --- /dev/null +++ b/backend/src/data-sources/data-sources.controller.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { DataSourcesController } from './data-sources.controller'; +import { DataSourcesService } from './data-sources.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('DataSourcesController (e2e)', () => { + let app: INestApplication; + + const mockDataSource = { + id: 1, + name: 'Test API', + description: 'A test data source', + type: 'json', + url: 'https://api.example.com/data', + method: 'GET', + isActive: true, + refreshInterval: 300, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockDataSourcesService = { + create: async () => mockDataSource, + findAll: async () => ({ + items: [mockDataSource], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }), + findOne: async () => mockDataSource, + update: async () => ({ ...mockDataSource, name: 'Updated API' }), + remove: async () => ({ message: 'Data source deleted successfully' }), + testUrl: async () => ({ success: true, data: {}, fields: [] }), + testFetch: async () => ({ success: true, data: {}, fields: [] }), + refresh: async () => ({ success: true, data: {}, dataSource: mockDataSource }), + getCachedData: async () => ({ key: 'value' }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [DataSourcesController], + providers: [ + { provide: DataSourcesService, useValue: mockDataSourcesService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /data-sources', () => { + it('should return paginated list of data sources', async () => { + const response = await request(app.getHttpServer()) + .get('/data-sources') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + expect(response.body.data).toHaveProperty('total'); + }); + }); + + describe('GET /data-sources/:id', () => { + it('should return a data source by ID', async () => { + const response = await request(app.getHttpServer()) + .get('/data-sources/1') + .expect(200); + + expect(response.body.data).toHaveProperty('id', 1); + expect(response.body.data).toHaveProperty('name', 'Test API'); + }); + }); + + describe('POST /data-sources', () => { + it('should create a new data source', async () => { + const response = await request(app.getHttpServer()) + .post('/data-sources') + .send({ name: 'New API', type: 'json', url: 'https://api.example.com/new' }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('url'); + }); + }); + + describe('DELETE /data-sources/:id', () => { + it('should delete a data source', async () => { + const response = await request(app.getHttpServer()) + .delete('/data-sources/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Data source deleted successfully'); + }); + }); +}); diff --git a/backend/src/data-sources/data-sources.controller.ts b/backend/src/data-sources/data-sources.controller.ts new file mode 100644 index 0000000..86a1229 --- /dev/null +++ b/backend/src/data-sources/data-sources.controller.ts @@ -0,0 +1,90 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { DataSourcesService } from './data-sources.service'; +import { CreateDataSourceDto } from './dto/create-data-source.dto'; +import { UpdateDataSourceDto } from './dto/update-data-source.dto'; +import { TestUrlDto } from './dto/test-url.dto'; + +@ApiTags('Data Sources') +@ApiBearerAuth() +@Controller('data-sources') +export class DataSourcesController { + constructor(private readonly dataSourcesService: DataSourcesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new data source' }) + create(@Body() createDataSourceDto: CreateDataSourceDto) { + return this.dataSourcesService.create(createDataSourceDto); + } + + @Get() + @ApiOperation({ summary: 'Get all data sources with pagination' }) + findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('activeOnly') activeOnly?: string, + ) { + const pageNum = Math.max(parseInt(page || '1', 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit || '20', 10) || 20, 1), 100); + return this.dataSourcesService.findAll(pageNum, limitNum, activeOnly === 'true'); + } + + @Post('test-url') + @ApiOperation({ + summary: 'Test a URL without saving', + description: + 'Fetches data from the provided URL and returns available fields with their types. Use this to preview what data is available before creating a data source.', + }) + testUrl(@Body() testUrlDto: TestUrlDto) { + return this.dataSourcesService.testUrl(testUrlDto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a data source by ID' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.dataSourcesService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a data source' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateDataSourceDto: UpdateDataSourceDto, + ) { + return this.dataSourcesService.update(id, updateDataSourceDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a data source' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.dataSourcesService.remove(id); + } + + @Post(':id/test') + @ApiOperation({ summary: 'Test fetch data from source without saving' }) + testFetch(@Param('id', ParseIntPipe) id: number) { + return this.dataSourcesService.testFetch(id); + } + + @Post(':id/refresh') + @ApiOperation({ summary: 'Force refresh and cache data from source' }) + refresh(@Param('id', ParseIntPipe) id: number) { + return this.dataSourcesService.refresh(id); + } + + @Get(':id/data') + @ApiOperation({ summary: 'Get cached data, refreshing if stale' }) + getCachedData(@Param('id', ParseIntPipe) id: number) { + return this.dataSourcesService.getCachedData(id); + } +} diff --git a/backend/src/data-sources/data-sources.module.ts b/backend/src/data-sources/data-sources.module.ts new file mode 100644 index 0000000..c6630f1 --- /dev/null +++ b/backend/src/data-sources/data-sources.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DataSourcesController } from './data-sources.controller'; +import { DataSourcesService } from './data-sources.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [DataSourcesController], + providers: [DataSourcesService], + exports: [DataSourcesService], +}) +export class DataSourcesModule {} diff --git a/backend/src/data-sources/data-sources.service.test.ts b/backend/src/data-sources/data-sources.service.test.ts new file mode 100644 index 0000000..6953c07 --- /dev/null +++ b/backend/src/data-sources/data-sources.service.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { DataSourcesService } from './data-sources.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; +import { createMock } from '../test/mocks/helpers'; + +describe('DataSourcesService', () => { + let service: DataSourcesService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new DataSourcesService(mockPrisma as any); + }); + + // ─── extractFieldsWithMeta ─────────────────────────────────────────── + + describe('extractFieldsWithMeta()', () => { + it('should extract flat object fields', () => { + const fields = service.extractFieldsWithMeta({ price: 100, name: 'widget' }); + expect(fields).toContainEqual({ path: 'price', type: 'number', sample: 100 }); + expect(fields).toContainEqual({ path: 'name', type: 'string', sample: 'widget' }); + }); + + it('should extract nested object fields', () => { + const fields = service.extractFieldsWithMeta({ a: { b: 1 } }); + expect(fields).toContainEqual({ path: 'a.b', type: 'number', sample: 1 }); + }); + + it('should handle array of primitives', () => { + const fields = service.extractFieldsWithMeta([10, 20, 30]); + expect(fields).toContainEqual({ path: '[0]', type: 'number', sample: 10 }); + expect(fields).toContainEqual({ path: '[1]', type: 'number', sample: 20 }); + expect(fields).toContainEqual({ path: '[2]', type: 'number', sample: 30 }); + }); + + it('should handle array of objects', () => { + const fields = service.extractFieldsWithMeta([{ name: 'test', value: 42 }]); + expect(fields).toContainEqual({ path: '[0].name', type: 'string', sample: 'test' }); + expect(fields).toContainEqual({ path: '[0].value', type: 'number', sample: 42 }); + }); + + it('should handle RSS feed structure with items array', () => { + const rss = { + title: 'My Feed', + description: 'A feed', + link: 'https://example.com', + items: [{ title: 'Post 1' }], + }; + const fields = service.extractFieldsWithMeta(rss); + expect(fields).toContainEqual({ path: 'title', type: 'string', sample: 'My Feed' }); + expect(fields).toContainEqual({ path: 'description', type: 'string', sample: 'A feed' }); + expect(fields).toContainEqual({ path: 'link', type: 'string', sample: 'https://example.com', isLink: true }); + expect(fields).toContainEqual({ path: 'items', type: 'array', sample: 'Array(1)' }); + expect(fields).toContainEqual({ path: 'items[0].title', type: 'string', sample: 'Post 1' }); + }); + + it('should detect image URLs', () => { + const fields = service.extractFieldsWithMeta({ avatar: 'https://example.com/photo.jpg' }); + expect(fields[0].isImageUrl).toBe(true); + }); + + it('should detect plain links', () => { + const fields = service.extractFieldsWithMeta({ url: 'https://example.com/page' }); + expect(fields[0].isLink).toBe(true); + expect(fields[0].isImageUrl).toBeUndefined(); + }); + + it('should truncate string samples at 100 characters', () => { + const longString = 'x'.repeat(150); + const fields = service.extractFieldsWithMeta({ text: longString }); + expect(fields[0].sample).toBe('x'.repeat(100) + '...'); + }); + + it('should return empty array for null input', () => { + expect(service.extractFieldsWithMeta(null)).toEqual([]); + }); + + it('should return empty array for undefined input', () => { + expect(service.extractFieldsWithMeta(undefined)).toEqual([]); + }); + + it('should return empty array for empty array input', () => { + expect(service.extractFieldsWithMeta([])).toEqual([]); + }); + + it('should handle nested arrays inside objects', () => { + const data = { tags: ['a', 'b'] }; + const fields = service.extractFieldsWithMeta(data); + expect(fields).toContainEqual({ path: 'tags', type: 'array', sample: 'Array(2)' }); + expect(fields).toContainEqual({ path: 'tags[0]', type: 'string', sample: 'a' }); + }); + }); + + // ─── extractWithJsonPath ───────────────────────────────────────────── + + describe('extractWithJsonPath()', () => { + const extract = (data: unknown, path: string) => + (service as any).extractWithJsonPath(data, path); + + it('should return full data for empty path', () => { + const data = { a: 1 }; + expect(extract(data, '')).toEqual({ a: 1 }); + }); + + it('should return full data for "$"', () => { + const data = { a: 1 }; + expect(extract(data, '$')).toEqual({ a: 1 }); + }); + + it('should extract simple field with $.field', () => { + expect(extract({ price: 100 }, '$.price')).toBe(100); + }); + + it('should extract nested field with $.a.b', () => { + expect(extract({ a: { b: 42 } }, '$.a.b')).toBe(42); + }); + + it('should extract array element with $.arr[0]', () => { + expect(extract({ arr: [10, 20] }, '$.arr[0]')).toBe(10); + }); + + it('should return full array with wildcard $.arr[*]', () => { + expect(extract({ arr: [1, 2, 3] }, '$.arr[*]')).toEqual([1, 2, 3]); + }); + + it('should map field name over array with $.items.title', () => { + const data = { items: [{ title: 'A' }, { title: 'B' }] }; + expect(extract(data, '$.items.title')).toEqual(['A', 'B']); + }); + + it('should return null when traversing through null', () => { + expect(extract({ a: null }, '$.a.b.c')).toBeNull(); + }); + }); + + // ─── parseRss ──────────────────────────────────────────────────────── + + describe('parseRss()', () => { + const parseRss = (xml: string) => (service as any).parseRss(xml); + + it('should parse standard RSS feed with tags', () => { + const xml = ` + + + My Blog + A blog + + Post 1 + https://example.com/1 + Mon, 01 Jan 2024 + + + Post 2 + + + `; + const result = parseRss(xml); + expect(result.title).toBe('My Blog'); + expect(result.description).toBe('A blog'); + expect(result.items).toHaveLength(2); + expect(result.items[0].title).toBe('Post 1'); + expect(result.items[0].link).toBe('https://example.com/1'); + expect(result.items[1].title).toBe('Post 2'); + }); + + it('should parse Atom feed with tags', () => { + const xml = ` + + Atom Feed + + Entry 1 +

Summary here + + + `; + const result = parseRss(xml); + expect(result.title).toBe('Atom Feed'); + expect(result.items).toHaveLength(1); + expect(result.items[0].title).toBe('Entry 1'); + expect(result.items[0].description).toBe('Summary here'); + expect(result.items[0].link).toBe('https://example.com/entry1'); + }); + + it('should handle CDATA content', () => { + const xml = ` + + + <![CDATA[CDATA Title]]> + + <![CDATA[Item CDATA]]> + + + `; + const result = parseRss(xml); + expect(result.title).toBe('CDATA Title'); + expect(result.items[0].title).toBe('Item CDATA'); + }); + + it('should return empty items for feed with no items', () => { + const xml = `Empty`; + const result = parseRss(xml); + expect(result.title).toBe('Empty'); + expect(result.items).toEqual([]); + }); + + it('should truncate item description to 500 chars', () => { + const longDesc = 'a'.repeat(600); + const xml = `${longDesc}`; + const result = parseRss(xml); + expect(result.items[0].description!.length).toBe(500); + }); + }); + + // ─── cleanXmlText ──────────────────────────────────────────────────── + + describe('cleanXmlText()', () => { + const clean = (text: string) => (service as any).cleanXmlText(text); + + it('should strip HTML tags', () => { + expect(clean('

Hello world

')).toBe('Hello world'); + }); + + it('should decode XML entities', () => { + expect(clean('& < > " '')).toBe('& < > " \''); + }); + + it('should trim and collapse whitespace', () => { + expect(clean(' hello world ')).toBe('hello world'); + }); + }); + + // ─── looksLikeImageUrl / looksLikeUrl ──────────────────────────────── + + describe('looksLikeImageUrl()', () => { + const isImage = (v: string) => (service as any).looksLikeImageUrl(v); + + it('should detect .jpg extension', () => { + expect(isImage('https://example.com/photo.jpg')).toBe(true); + }); + + it('should detect .png with query string', () => { + expect(isImage('https://example.com/img.png?w=200')).toBe(true); + }); + + it('should detect /image/ path pattern', () => { + expect(isImage('https://cdn.example.com/image/abc123')).toBe(true); + }); + + it('should reject plain URL without image indicators', () => { + expect(isImage('https://example.com/api/data')).toBe(false); + }); + }); + + describe('looksLikeUrl()', () => { + const isUrl = (v: string) => (service as any).looksLikeUrl(v); + + it('should detect http:// URLs', () => { + expect(isUrl('http://example.com')).toBe(true); + }); + + it('should detect https:// URLs', () => { + expect(isUrl('https://example.com')).toBe(true); + }); + + it('should reject non-URL strings', () => { + expect(isUrl('just a string')).toBe(false); + }); + }); + + // ─── CRUD with mocked Prisma ───────────────────────────────────────── + + describe('findOne()', () => { + it('should return data source when found', async () => { + const ds = { id: 1, name: 'Test', customWidgets: [], headers: null }; + mockPrisma.dataSource.findUnique.mockResolvedValue(ds); + const result = await service.findOne(1); + expect(result).toEqual(ds); + }); + + it('should throw NotFoundException when not found', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove()', () => { + it('should throw NotFoundException when data source does not exist', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when widgets are using it', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue({ + id: 1, + name: 'Used Source', + _count: { customWidgets: 3 }, + }); + await expect(service.remove(1)).rejects.toThrow(BadRequestException); + }); + + it('should delete successfully when no widgets reference it', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue({ + id: 1, + name: 'Unused Source', + _count: { customWidgets: 0 }, + }); + mockPrisma.dataSource.delete.mockResolvedValue({}); + const result = await service.remove(1); + expect(result).toEqual({ message: 'Data source deleted successfully' }); + expect(mockPrisma.dataSource.delete.calls).toHaveLength(1); + }); + }); + + describe('getCachedData()', () => { + it('should throw NotFoundException when data source does not exist', async () => { + mockPrisma.dataSource.findUnique.mockResolvedValue(null); + await expect(service.getCachedData(999)).rejects.toThrow(NotFoundException); + }); + + it('should return cached data when still fresh', async () => { + const cachedData = { price: 100 }; + mockPrisma.dataSource.findUnique.mockResolvedValue({ + id: 1, + lastData: cachedData, + lastFetchedAt: new Date(), // just fetched + refreshInterval: 300, + headers: null, + }); + const result = await service.getCachedData(1); + expect(result).toEqual(cachedData); + }); + + it('should return stale cached data when refresh fails', async () => { + const staleData = { price: 50 }; + mockPrisma.dataSource.findUnique.mockResolvedValue({ + id: 1, + lastData: staleData, + lastFetchedAt: new Date(0), // very old + refreshInterval: 300, + type: 'json', + url: 'https://fail.example.com', + method: 'GET', + headers: null, + }); + + // Override fetchDataFromSource to simulate failure + (service as any).fetchDataFromSource = createMock().mockRejectedValue( + new Error('Network error'), + ); + + const result = await service.getCachedData(1); + expect(result).toEqual(staleData); + }); + + it('should fetch fresh data when cache is stale', async () => { + const freshData = { price: 200 }; + mockPrisma.dataSource.findUnique.mockResolvedValue({ + id: 1, + lastData: null, + lastFetchedAt: null, + refreshInterval: 300, + type: 'json', + url: 'https://api.example.com', + method: 'GET', + headers: null, + }); + mockPrisma.dataSource.update.mockResolvedValue({}); + + (service as any).fetchDataFromSource = createMock().mockResolvedValue(freshData); + + const result = await service.getCachedData(1); + expect(result).toEqual(freshData); + expect(mockPrisma.dataSource.update.calls).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/data-sources/data-sources.service.ts b/backend/src/data-sources/data-sources.service.ts new file mode 100644 index 0000000..47b7039 --- /dev/null +++ b/backend/src/data-sources/data-sources.service.ts @@ -0,0 +1,831 @@ +import { + Injectable, + NotFoundException, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateDataSourceDto } from './dto/create-data-source.dto'; +import { UpdateDataSourceDto } from './dto/update-data-source.dto'; +import { TestUrlDto } from './dto/test-url.dto'; +import { wrapListResponse, wrapPaginatedResponse } from '../common/utils/response.util'; +import axios from 'axios'; +import { + validateUrlSafety, + isPrivateIp, + createSafeHttpAgent, + createSafeHttpsAgent, +} from '../common/utils/url-safety'; + +/** + * Field metadata extracted from API response. + * Used to show users what fields are available before creating a data source. + */ +export interface FieldMeta { + path: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'; + sample: unknown; + isImageUrl?: boolean; + isLink?: boolean; +} + +@Injectable() +export class DataSourcesService { + private readonly logger = new Logger(DataSourcesService.name); + + constructor(private prisma: PrismaService) {} + + /** + * Create a new data source and auto-test it + */ + async create(createDataSourceDto: CreateDataSourceDto) { + const dataSource = await this.prisma.dataSource.create({ + data: { + name: createDataSourceDto.name, + description: createDataSourceDto.description, + type: createDataSourceDto.type, + url: createDataSourceDto.url, + method: createDataSourceDto.method || 'GET', + headers: (createDataSourceDto.headers || undefined) as object | undefined, + refreshInterval: createDataSourceDto.refreshInterval || 300, + jsonPath: createDataSourceDto.jsonPath, + isActive: createDataSourceDto.isActive ?? true, + }, + }); + + this.logger.log(`Data source created: ${dataSource.name}`); + + // Auto-test the new data source to set initial status + try { + const data = await this.fetchDataFromSource({ + ...dataSource, + headers: dataSource.headers as object | null, + }); + return await this.prisma.dataSource.update({ + where: { id: dataSource.id }, + data: { + lastData: data as object, + lastFetchedAt: new Date(), + lastError: null, + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return await this.prisma.dataSource.update({ + where: { id: dataSource.id }, + data: { + lastError: errorMessage, + lastFetchedAt: new Date(), + }, + }); + } + } + + /** + * Find all data sources with pagination + */ + async findAll(page = 1, limit = 20, activeOnly = false) { + const where = activeOnly ? { isActive: true } : {}; + const skip = (page - 1) * limit; + + const [dataSources, total] = await Promise.all([ + this.prisma.dataSource.findMany({ + where, + include: { + _count: { + select: { customWidgets: true }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + }), + this.prisma.dataSource.count({ where }), + ]); + + const masked = dataSources.map(ds => ({ + ...ds, + headers: this.maskSensitiveHeaders(ds.headers as Record | null), + })); + + return wrapPaginatedResponse(masked, total, page, limit); + } + + /** + * Find one data source by ID + */ + async findOne(id: number) { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id }, + include: { + customWidgets: true, + }, + }); + + if (!dataSource) { + throw new NotFoundException('Data source not found'); + } + + return { + ...dataSource, + headers: this.maskSensitiveHeaders(dataSource.headers as Record | null), + }; + } + + /** + * Test a URL without saving - allows users to preview available fields + * before creating a data source. + */ + async testUrl(dto: TestUrlDto) { + try { + const data = await this.fetchDataFromSource({ + type: dto.type, + url: dto.url, + method: dto.method || 'GET', + headers: dto.headers || null, + }); + + // Extract all field paths with types and sample values + const fields = this.extractFieldsWithMeta(data); + + return { + success: true, + data, + fields, + fetchedAt: new Date().toISOString(), + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + return { + success: false, + error: errorMessage, + fields: [], + fetchedAt: new Date().toISOString(), + }; + } + } + + /** + * Extract all field paths from data with their types and sample values. + * This helps users understand what fields are available from an API. + * + * For arrays, generates paths for ALL indices: + * - arrayName (the array itself) + * - arrayName[0], arrayName[1], arrayName[2], etc. + * - arrayName[0].field (for object arrays) + */ + extractFieldsWithMeta(data: unknown, prefix = ''): FieldMeta[] { + const fields: FieldMeta[] = []; + + if (data === null || data === undefined) { + return fields; + } + + if (Array.isArray(data)) { + // For arrays, show ALL indices + data.forEach((item, index) => { + const itemPath = prefix ? `${prefix}[${index}]` : `[${index}]`; + + if (typeof item === 'object' && item !== null && !Array.isArray(item)) { + // Object in array - add fields from this object + Object.entries(item as Record).forEach( + ([key, value]) => { + const fullPath = `${itemPath}.${key}`; + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + // Nested object inside array item + fields.push(...this.extractFieldsWithMeta(value, fullPath)); + } else if (Array.isArray(value)) { + // Nested array inside array item + fields.push({ + path: fullPath, + type: 'array', + sample: `Array(${value.length})`, + }); + if (value.length > 0) { + fields.push(...this.extractFieldsWithMeta(value, fullPath)); + } + } else { + // Primitive field in array item + fields.push(this.createFieldMeta(fullPath, value)); + } + }, + ); + } else if (Array.isArray(item)) { + // Nested array + fields.push({ + path: itemPath, + type: 'array', + sample: `Array(${item.length})`, + }); + fields.push(...this.extractFieldsWithMeta(item, itemPath)); + } else { + // Primitive value in array + fields.push(this.createFieldMeta(itemPath, item)); + } + }); + } else if (typeof data === 'object') { + const obj = data as Record; + + // Handle RSS feed structure specially + if ('items' in obj && Array.isArray(obj.items)) { + // Add feed-level fields + if (obj.title) { + fields.push(this.createFieldMeta('title', obj.title)); + } + if (obj.description) { + fields.push(this.createFieldMeta('description', obj.description)); + } + if (obj.link) { + fields.push(this.createFieldMeta('link', obj.link)); + } + + // Add items array + fields.push({ + path: 'items', + type: 'array', + sample: `Array(${obj.items.length})`, + }); + + // Add ALL item fields with full paths + fields.push(...this.extractFieldsWithMeta(obj.items, 'items')); + } else { + // Regular object - extract all fields recursively + Object.entries(obj).forEach(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + // Nested object - recurse + fields.push(...this.extractFieldsWithMeta(value, path)); + } else if (Array.isArray(value)) { + // Array - add the array path and recurse + fields.push({ + path, + type: 'array', + sample: `Array(${value.length})`, + }); + if (value.length > 0) { + fields.push(...this.extractFieldsWithMeta(value, path)); + } + } else { + // Primitive value + fields.push(this.createFieldMeta(path, value)); + } + }); + } + } + + return fields; + } + + /** + * Create field metadata from a path and value + */ + private createFieldMeta(path: string, value: unknown): FieldMeta { + const type = this.getValueType(value); + const meta: FieldMeta = { + path, + type, + sample: this.truncateSample(value), + }; + + // Detect if string looks like an image URL + if (type === 'string' && typeof value === 'string') { + if (this.looksLikeImageUrl(value)) { + meta.isImageUrl = true; + } else if (this.looksLikeUrl(value)) { + meta.isLink = true; + } + } + + return meta; + } + + /** + * Get the type of a value + */ + private getValueType( + value: unknown, + ): 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' { + if (value === null || value === undefined) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'object') return 'object'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'boolean') return 'boolean'; + return 'string'; + } + + /** + * Truncate sample value for display + */ + private truncateSample(value: unknown): unknown { + if (typeof value === 'string' && value.length > 100) { + return value.substring(0, 100) + '...'; + } + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + if (typeof value === 'object' && value !== null) { + return '{...}'; + } + return value; + } + + /** + * Check if a string looks like an image URL + */ + private looksLikeImageUrl(value: string): boolean { + const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i; + const imagePatterns = + /\/(image|img|photo|picture|media)\//i; + + return ( + imageExtensions.test(value) || + (imagePatterns.test(value) && value.startsWith('http')) + ); + } + + /** + * Check if a string looks like a URL + */ + private looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value); + } + + /** + * Update data source + */ + async update(id: number, updateDataSourceDto: UpdateDataSourceDto) { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id }, + }); + + if (!dataSource) { + throw new NotFoundException('Data source not found'); + } + + const updatedDataSource = await this.prisma.dataSource.update({ + where: { id }, + data: { + name: updateDataSourceDto.name, + description: updateDataSourceDto.description, + type: updateDataSourceDto.type, + url: updateDataSourceDto.url, + method: updateDataSourceDto.method, + headers: updateDataSourceDto.headers, + refreshInterval: updateDataSourceDto.refreshInterval, + jsonPath: updateDataSourceDto.jsonPath, + isActive: updateDataSourceDto.isActive, + }, + }); + + this.logger.log(`Data source updated: ${updatedDataSource.name}`); + + return updatedDataSource; + } + + /** + * Delete data source + */ + async remove(id: number) { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id }, + include: { + _count: { + select: { customWidgets: true }, + }, + }, + }); + + if (!dataSource) { + throw new NotFoundException('Data source not found'); + } + + if (dataSource._count.customWidgets > 0) { + throw new BadRequestException( + `Cannot delete data source with ${dataSource._count.customWidgets} widget(s) using it`, + ); + } + + await this.prisma.dataSource.delete({ + where: { id }, + }); + + this.logger.log(`Data source deleted: ${dataSource.name}`); + + return { message: 'Data source deleted successfully' }; + } + + /** + * Test fetch data from the source and update status + * Returns the data along with extracted fields for the widget editor + */ + async testFetch(id: number) { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id }, + }); + + if (!dataSource) { + throw new NotFoundException('Data source not found'); + } + + try { + const data = await this.fetchDataFromSource({ + ...dataSource, + headers: dataSource.headers as object | null, + }); + // Extract fields from the data so the widget editor can show a dropdown + const fields = this.extractFieldsWithMeta(data); + + // Update status to show API is working + await this.prisma.dataSource.update({ + where: { id }, + data: { + lastData: data as object, + lastFetchedAt: new Date(), + lastError: null, + }, + }); + + return { + success: true, + data, + fields, + fetchedAt: new Date().toISOString(), + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + // Update status to show API error + await this.prisma.dataSource.update({ + where: { id }, + data: { + lastError: errorMessage, + lastFetchedAt: new Date(), + }, + }); + + return { + success: false, + error: errorMessage, + fetchedAt: new Date().toISOString(), + }; + } + } + + /** + * Force refresh data and cache it + */ + async refresh(id: number) { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id }, + }); + + if (!dataSource) { + throw new NotFoundException('Data source not found'); + } + + try { + const data = await this.fetchDataFromSource({ + ...dataSource, + headers: dataSource.headers as object | null, + }); + + // Update cached data + const updatedDataSource = await this.prisma.dataSource.update({ + where: { id }, + data: { + lastData: data as object, + lastFetchedAt: new Date(), + lastError: null, + }, + }); + + this.logger.log(`Data source refreshed: ${dataSource.name}`); + + return { + success: true, + data, + dataSource: updatedDataSource, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + // Update error status + await this.prisma.dataSource.update({ + where: { id }, + data: { + lastError: errorMessage, + lastFetchedAt: new Date(), + }, + }); + + this.logger.error(`Data source fetch failed: ${dataSource.name} - ${errorMessage}`); + + throw new BadRequestException(`Failed to fetch data: ${errorMessage}`); + } + } + + /** + * Fetch data from a data source + */ + async fetchDataFromSource(dataSource: { + type: string; + url: string; + method: string; + headers?: object | null; + jsonPath?: string | null; + }): Promise { + try { + await validateUrlSafety(dataSource.url); + } catch (err) { + throw new BadRequestException(err instanceof Error ? err.message : 'Invalid URL'); + } + const headers = (dataSource.headers as Record) || {}; + + if (dataSource.type === 'json') { + return this.fetchJson(dataSource.url, dataSource.method, headers, dataSource.jsonPath); + } else if (dataSource.type === 'rss') { + return this.fetchRss(dataSource.url, headers); + } + + throw new Error(`Unknown data source type: ${dataSource.type}`); + } + + /** + * Mask sensitive values in headers to prevent leaking API keys/tokens in responses + */ + private maskSensitiveHeaders(headers: Record | null): Record | null { + if (!headers) return null; + const sensitiveKeys = ['authorization', 'x-api-key', 'api-key', 'token', 'bearer', 'secret']; + const masked = { ...headers }; + for (const key of Object.keys(masked)) { + if (sensitiveKeys.some(s => key.toLowerCase().includes(s))) { + const val = masked[key]; + masked[key] = val && val.length > 4 ? val.slice(0, 4) + '••••••••' : '••••••••'; + } + } + return masked; + } + + /** + * Fetch JSON data from an API + */ + private async fetchJson( + url: string, + method: string, + headers: Record, + jsonPath?: string | null, + ): Promise { + // Use DNS-pinning agents to prevent DNS rebinding attacks (TOCTOU) + const response = await axios({ + method: method as 'GET' | 'POST', + url, + headers, + timeout: 30000, + maxContentLength: 5 * 1024 * 1024, + maxBodyLength: 5 * 1024 * 1024, + httpAgent: createSafeHttpAgent(), + httpsAgent: createSafeHttpsAgent(), + }); + + let data = response.data; + + // Apply JSONPath extraction if specified + if (jsonPath) { + data = this.extractWithJsonPath(data, jsonPath); + } + + return data; + } + + /** + * Fetch and parse RSS/Atom feed + */ + private async fetchRss( + url: string, + headers: Record, + ): Promise { + // Use DNS-pinning agents to prevent DNS rebinding attacks (TOCTOU) + const response = await axios({ + method: 'GET', + url, + headers: { + ...headers, + Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml', + }, + timeout: 30000, + responseType: 'text', + maxContentLength: 1 * 1024 * 1024, + maxBodyLength: 1 * 1024 * 1024, + httpAgent: createSafeHttpAgent(), + httpsAgent: createSafeHttpsAgent(), + }); + + return this.parseRss(response.data); + } + + /** + * Parse RSS/Atom XML to normalized JSON + */ + private parseRss(xml: string): { + title?: string; + description?: string; + link?: string; + items: Array<{ + title?: string; + description?: string; + link?: string; + pubDate?: string; + }>; + } { + // Simple RSS/Atom parser without external dependencies + const result: { + title?: string; + description?: string; + link?: string; + items: Array<{ + title?: string; + description?: string; + link?: string; + pubDate?: string; + }>; + } = { items: [] }; + + // Extract channel/feed info + const titleMatch = xml.match(/]*>(?:)?<\/title>/i); + if (titleMatch) { + result.title = this.cleanXmlText(titleMatch[1]); + } + + const descMatch = xml.match(/]*>(?:)?<\/description>/i); + if (descMatch) { + result.description = this.cleanXmlText(descMatch[1]); + } + + // Extract items (RSS) or entries (Atom) + const itemRegex = /]*>([\s\S]*?)<\/item>|]*>([\s\S]*?)<\/entry>/gi; + let itemMatch; + + while ((itemMatch = itemRegex.exec(xml)) !== null) { + const itemContent = itemMatch[1] || itemMatch[2]; + const item: { + title?: string; + description?: string; + link?: string; + pubDate?: string; + } = {}; + + // Title + const itemTitle = itemContent.match(/]*>(?:)?<\/title>/i); + if (itemTitle) { + item.title = this.cleanXmlText(itemTitle[1]); + } + + // Description/Summary/Content + const itemDesc = itemContent.match( + /<(?:description|summary|content)[^>]*>(?:)?<\/(?:description|summary|content)>/i, + ); + if (itemDesc) { + item.description = this.cleanXmlText(itemDesc[1]).substring(0, 500); + } + + // Link + const itemLink = itemContent.match(/]*>([^<]*)<\/link>|]*href=["']([^"']+)["']/i); + if (itemLink) { + item.link = itemLink[1] || itemLink[2]; + } + + // Publication date + const itemDate = itemContent.match( + /<(?:pubDate|published|updated)[^>]*>([^<]*)<\/(?:pubDate|published|updated)>/i, + ); + if (itemDate) { + item.pubDate = itemDate[1]; + } + + result.items.push(item); + } + + return result; + } + + /** + * Clean XML text content + */ + private cleanXmlText(text: string): string { + return text + .replace(/<[^>]+>/g, '') // Remove HTML tags + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Extract data using JSONPath-like syntax + * Supports: $.field, $.nested.field, $.array[0], $.array[*].field + */ + private extractWithJsonPath(data: unknown, path: string): unknown { + if (!path || path === '$') { + return data; + } + + // Remove leading $. if present + const cleanPath = path.replace(/^\$\.?/, ''); + const parts = cleanPath.split(/\.|\[|\]/).filter(Boolean); + + let current: unknown = data; + + for (const part of parts) { + if (current === null || current === undefined) { + return null; + } + + if (part === '*' && Array.isArray(current)) { + // Wildcard for arrays - return all items + return current; + } + + if (Array.isArray(current)) { + const index = parseInt(part, 10); + if (!isNaN(index)) { + current = current[index]; + } else { + // Map over array to extract field from each item + current = current.map((item) => + typeof item === 'object' && item !== null + ? (item as Record)[part] + : undefined, + ); + } + } else if (typeof current === 'object' && current !== null) { + current = (current as Record)[part]; + } else { + return null; + } + } + + return current; + } + + /** + * Get cached data for a data source, refreshing if stale + */ + async getCachedData(id: number): Promise { + const dataSource = await this.prisma.dataSource.findUnique({ + where: { id }, + }); + + if (!dataSource) { + throw new NotFoundException('Data source not found'); + } + + // Check if data is stale + const isStale = + !dataSource.lastFetchedAt || + Date.now() - dataSource.lastFetchedAt.getTime() > + dataSource.refreshInterval * 1000; + + if (isStale || !dataSource.lastData) { + try { + const data = await this.fetchDataFromSource({ + ...dataSource, + headers: dataSource.headers as object | null, + }); + await this.prisma.dataSource.update({ + where: { id }, + data: { + lastData: data as object, + lastFetchedAt: new Date(), + lastError: null, + }, + }); + return data; + } catch (error) { + // Return cached data if available, even if stale + if (dataSource.lastData) { + return dataSource.lastData; + } + throw error; + } + } + + return dataSource.lastData; + } +} diff --git a/backend/src/data-sources/dto/create-data-source.dto.ts b/backend/src/data-sources/dto/create-data-source.dto.ts new file mode 100644 index 0000000..ae9e676 --- /dev/null +++ b/backend/src/data-sources/dto/create-data-source.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsBoolean, + IsInt, + IsUrl, + Min, + MaxLength, +} from 'class-validator'; + +export class CreateDataSourceDto { + @ApiProperty({ example: 'Bitcoin Price API' }) + @IsString() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ example: 'Fetches current BTC price from CoinGecko' }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiProperty({ + example: 'json', + enum: ['json', 'rss'], + description: 'Type of data source', + }) + @IsString() + @IsIn(['json', 'rss']) + type: string; + + @ApiProperty({ + example: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd', + description: 'API endpoint or RSS feed URL', + }) + @IsString() + @IsUrl({}, { message: 'URL must be a valid URL' }) + url: string; + + @ApiPropertyOptional({ + example: 'GET', + default: 'GET', + enum: ['GET', 'POST'], + description: 'HTTP method for JSON APIs', + }) + @IsOptional() + @IsString() + @IsIn(['GET', 'POST']) + method?: string; + + @ApiPropertyOptional({ + example: { 'X-API-Key': 'your-api-key' }, + description: 'Custom HTTP headers (e.g., for API keys)', + }) + @IsOptional() + @IsObject() + headers?: Record; + + @ApiPropertyOptional({ + example: 300, + default: 300, + description: 'Refresh interval in seconds', + }) + @IsOptional() + @IsInt() + @Min(60) + refreshInterval?: number; + + @ApiPropertyOptional({ + example: '$.bitcoin.usd', + description: 'JSONPath expression to extract specific data', + }) + @IsOptional() + @IsString() + @MaxLength(500) + jsonPath?: string; + + @ApiPropertyOptional({ + example: true, + default: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/data-sources/dto/test-url.dto.ts b/backend/src/data-sources/dto/test-url.dto.ts new file mode 100644 index 0000000..7714d24 --- /dev/null +++ b/backend/src/data-sources/dto/test-url.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsOptional, IsIn, IsObject, IsUrl, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * DTO for testing a URL without saving a data source. + * This allows users to preview what fields are available from an API + * before committing to creating a data source. + */ +export class TestUrlDto { + @ApiProperty({ description: 'The URL to fetch data from' }) + @IsUrl({ protocols: ['http', 'https'], require_protocol: true }) + url: string; + + @ApiProperty({ description: 'Type of data source', enum: ['json', 'rss'] }) + @IsIn(['json', 'rss']) + type: 'json' | 'rss'; + + @ApiPropertyOptional({ description: 'HTTP method (for JSON)', default: 'GET' }) + @IsOptional() + @IsString() + @IsIn(['GET', 'POST']) + method?: string; + + @ApiPropertyOptional({ description: 'Custom headers to include in the request' }) + @IsOptional() + @IsObject() + headers?: Record; +} diff --git a/backend/src/data-sources/dto/update-data-source.dto.ts b/backend/src/data-sources/dto/update-data-source.dto.ts new file mode 100644 index 0000000..9f2ddce --- /dev/null +++ b/backend/src/data-sources/dto/update-data-source.dto.ts @@ -0,0 +1,82 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsBoolean, + IsInt, + IsUrl, + Min, + MaxLength, +} from 'class-validator'; + +export class UpdateDataSourceDto { + @ApiPropertyOptional({ example: 'Bitcoin Price API' }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ example: 'Fetches current BTC price' }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ + example: 'json', + enum: ['json', 'rss'], + }) + @IsOptional() + @IsString() + @IsIn(['json', 'rss']) + type?: string; + + @ApiPropertyOptional({ + example: 'https://api.example.com/data', + }) + @IsOptional() + @IsString() + @IsUrl({}, { message: 'URL must be a valid URL' }) + url?: string; + + @ApiPropertyOptional({ + example: 'GET', + enum: ['GET', 'POST'], + }) + @IsOptional() + @IsString() + @IsIn(['GET', 'POST']) + method?: string; + + @ApiPropertyOptional({ + example: { 'X-API-Key': 'your-api-key' }, + }) + @IsOptional() + @IsObject() + headers?: Record; + + @ApiPropertyOptional({ + example: 300, + }) + @IsOptional() + @IsInt() + @Min(60) + refreshInterval?: number; + + @ApiPropertyOptional({ + example: '$.data.price', + }) + @IsOptional() + @IsString() + @MaxLength(500) + jsonPath?: string; + + @ApiPropertyOptional({ + example: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/devices/devices.controller.test.ts b/backend/src/devices/devices.controller.test.ts new file mode 100644 index 0000000..820fd7c --- /dev/null +++ b/backend/src/devices/devices.controller.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { DevicesController } from './devices.controller'; +import { DevicesService } from './devices.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('DevicesController (e2e)', () => { + let app: INestApplication; + + const mockDevice = { + id: 1, + name: 'Test Device', + macAddress: 'AA:BB:CC:DD:EE:FF', + apiKey: 'test-api-key', + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockDevicesService = { + create: async () => mockDevice, + findAll: async () => ({ + items: [mockDevice], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }), + findOne: async () => mockDevice, + update: async () => ({ ...mockDevice, name: 'Updated Device' }), + remove: async () => ({ message: 'Device deleted successfully' }), + regenerateApiKey: async () => ({ deviceId: 1, apiKey: 'new-api-key' }), + getDeviceLogs: async () => [], + triggerRefresh: async () => ({ message: 'Device refresh triggered', deviceId: 1 }), + unassignPlaylist: async () => ({ message: 'Playlist unassigned successfully' }), + getDisplayContent: async () => ({ deviceId: 1, screen: null }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [DevicesController], + providers: [ + { provide: DevicesService, useValue: mockDevicesService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /devices', () => { + it('should return paginated list of devices', async () => { + const response = await request(app.getHttpServer()) + .get('/devices') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + expect(response.body.data).toHaveProperty('total'); + }); + }); + + describe('GET /devices/:id', () => { + it('should return a device by ID', async () => { + const response = await request(app.getHttpServer()) + .get('/devices/1') + .expect(200); + + expect(response.body.data).toHaveProperty('id', 1); + expect(response.body.data).toHaveProperty('name', 'Test Device'); + }); + }); + + describe('POST /devices', () => { + it('should create a new device', async () => { + const response = await request(app.getHttpServer()) + .post('/devices') + .send({ name: 'New Device', macAddress: 'AA:BB:CC:DD:EE:FF' }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('macAddress'); + }); + }); + + describe('PATCH /devices/:id', () => { + it('should update a device', async () => { + const response = await request(app.getHttpServer()) + .patch('/devices/1') + .send({ name: 'Updated Device' }) + .expect(200); + + expect(response.body.data).toHaveProperty('name', 'Updated Device'); + }); + }); + + describe('DELETE /devices/:id', () => { + it('should delete a device', async () => { + const response = await request(app.getHttpServer()) + .delete('/devices/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Device deleted successfully'); + }); + }); +}); diff --git a/backend/src/devices/devices.controller.ts b/backend/src/devices/devices.controller.ts new file mode 100644 index 0000000..c023cdf --- /dev/null +++ b/backend/src/devices/devices.controller.ts @@ -0,0 +1,161 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Patch, + Param, + Delete, + Headers, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiHeader, +} from '@nestjs/swagger'; +import { DevicesService } from './devices.service'; +import { CreateDeviceDto } from './dto/create-device.dto'; +import { UpdateDeviceDto } from './dto/update-device.dto'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('devices') +@Controller('devices') +export class DevicesController { + constructor(private readonly devicesService: DevicesService) {} + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Create a new device' }) + @ApiResponse({ status: 201, description: 'Device successfully created' }) + @ApiResponse({ status: 400, description: 'Device with MAC address already exists' }) + create(@Body() createDeviceDto: CreateDeviceDto) { + return this.devicesService.create(createDeviceDto); + } + + @Get() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get all devices with pagination' }) + @ApiResponse({ status: 200, description: 'List of devices' }) + findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = Math.max(parseInt(page || '1', 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit || '20', 10) || 20, 1), 100); + return this.devicesService.findAll(pageNum, limitNum); + } + + @Get(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get device by ID' }) + @ApiResponse({ status: 200, description: 'Device details' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.devicesService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update device (PATCH)' }) + @ApiResponse({ status: 200, description: 'Device successfully updated' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateDeviceDto: UpdateDeviceDto, + ) { + return this.devicesService.update(id, updateDeviceDto); + } + + @Put(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update device (PUT)' }) + @ApiResponse({ status: 200, description: 'Device successfully updated' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + updatePut( + @Param('id', ParseIntPipe) id: number, + @Body() updateDeviceDto: UpdateDeviceDto, + ) { + return this.devicesService.update(id, updateDeviceDto); + } + + @Delete(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Delete device' }) + @ApiResponse({ status: 200, description: 'Device successfully deleted' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.devicesService.remove(id); + } + + @Post(':id/regenerate-key') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Regenerate device API key' }) + @ApiResponse({ status: 200, description: 'API key regenerated' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + regenerateKey(@Param('id', ParseIntPipe) id: number) { + return this.devicesService.regenerateApiKey(id); + } + + @Get(':id/logs') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get device logs' }) + @ApiResponse({ status: 200, description: 'Device logs' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + getLogs(@Param('id', ParseIntPipe) id: number) { + return this.devicesService.getDeviceLogs(id); + } + + @Post(':id/refresh') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Trigger device refresh' }) + @ApiResponse({ status: 200, description: 'Device refresh triggered' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + triggerRefresh(@Param('id', ParseIntPipe) id: number) { + return this.devicesService.triggerRefresh(id); + } + + @Delete(':id/playlist') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Unassign playlist from device', + description: + 'Removes the playlist assignment from a device. The device will display ' + + 'the default "Hello World" screen until a new playlist is assigned.', + }) + @ApiResponse({ + status: 200, + description: 'Playlist unassigned successfully. Device will show default screen.', + }) + @ApiResponse({ status: 404, description: 'Device not found' }) + @ApiResponse({ status: 400, description: 'Device has no playlist assigned' }) + unassignPlaylist(@Param('id', ParseIntPipe) id: number) { + return this.devicesService.unassignPlaylist(id); + } + + /** + * PUBLIC ENDPOINTS FOR DEVICE COMMUNICATION + */ + + @Public() + @Get('display/content') + @ApiHeader({ + name: 'X-Device-Key', + description: 'Device API Key', + required: true, + }) + @ApiOperation({ + summary: 'Get display content for device (device polling endpoint)', + }) + @ApiResponse({ status: 200, description: 'Current screen to display' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + @ApiResponse({ status: 403, description: 'Device is inactive' }) + getDisplayContent(@Headers('x-device-key') apiKey: string) { + return this.devicesService.getDisplayContent(apiKey); + } + +} diff --git a/backend/src/devices/devices.module.ts b/backend/src/devices/devices.module.ts new file mode 100644 index 0000000..a8d06ef --- /dev/null +++ b/backend/src/devices/devices.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DevicesController } from './devices.controller'; +import { DevicesService } from './devices.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [DevicesController], + providers: [DevicesService], + exports: [DevicesService], +}) +export class DevicesModule {} diff --git a/backend/src/devices/devices.service.test.ts b/backend/src/devices/devices.service.test.ts new file mode 100644 index 0000000..a6a8dcc --- /dev/null +++ b/backend/src/devices/devices.service.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { DevicesService } from './devices.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; +import { createMock } from '../test/mocks/helpers'; + +describe('DevicesService', () => { + let service: DevicesService; + let mockPrisma: MockPrisma; + let mockEventsService: { + notifyDevicesRefresh: ReturnType; + notifyPlaylistUpdate: ReturnType; + }; + + // Helper to build a device-like object that serializeDevice can process + const makeDevice = (overrides: Record = {}) => ({ + id: 1, + name: 'Test Device', + macAddress: 'AA:BB:CC:DD:EE:FF', + apiKey: 'test-api-key', + isActive: true, + lastSeenAt: new Date(), + refreshRate: 900, + model: { name: 'og_png' }, + playlist: null, + ...overrides, + }); + + beforeEach(() => { + mockPrisma = createMockPrisma(); + mockEventsService = { + notifyDevicesRefresh: createMock().mockResolvedValue(undefined), + notifyPlaylistUpdate: createMock().mockResolvedValue(undefined), + }; + service = new DevicesService(mockPrisma as any, mockEventsService as any); + }); + + // ─── create ───────────────────────────────────────────────────────── + + describe('create()', () => { + it('should throw BadRequestException when MAC already exists', async () => { + mockPrisma.device.findUnique.mockResolvedValue(makeDevice()); + + await expect( + service.create({ name: 'New', macAddress: 'AA:BB:CC:DD:EE:FF' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should clean up blocked device before creating', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + mockPrisma.blockedDevice.deleteMany.mockResolvedValue({ count: 1 }); + mockPrisma.device.create.mockResolvedValue(makeDevice()); + + await service.create({ + name: 'New Device', + macAddress: 'AA:BB:CC:DD:EE:FF', + } as any); + + expect(mockPrisma.blockedDevice.deleteMany.calls).toHaveLength(1); + expect(mockPrisma.blockedDevice.deleteMany.calls[0][0].where.macAddress).toBe( + 'AA:BB:CC:DD:EE:FF', + ); + }); + + it('should create device with generated API key', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + mockPrisma.blockedDevice.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.device.create.mockResolvedValue(makeDevice()); + + const result = await service.create({ + name: 'New Device', + macAddress: 'AA:BB:CC:DD:EE:FF', + } as any); + + expect(mockPrisma.device.create.calls).toHaveLength(1); + // serializeDevice adds status and isOnline + expect(result).toHaveProperty('status'); + expect(result).toHaveProperty('isOnline'); + }); + }); + + // ─── findOne ──────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + + it('should return serialized device when found', async () => { + const device = makeDevice(); + mockPrisma.device.findUnique.mockResolvedValue(device); + + const result = await service.findOne(1); + + expect(result.id).toBe(1); + expect(result.name).toBe('Test Device'); + expect(result).toHaveProperty('status'); + expect(result).toHaveProperty('isOnline'); + }); + }); + + // ─── update ───────────────────────────────────────────────────────── + + describe('update()', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + + await expect(service.update(999, { name: 'Updated' } as any)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when changing to existing MAC', async () => { + const device = makeDevice(); + mockPrisma.device.findUnique + .mockImplementation((...args: any[]) => { + const where = args[0]?.where; + if (where?.id) return Promise.resolve(device); + if (where?.macAddress) return Promise.resolve(makeDevice({ id: 2 })); + return Promise.resolve(null); + }); + + await expect( + service.update(1, { macAddress: '11:22:33:44:55:66' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should set refreshPending when playlist changes', async () => { + const device = makeDevice({ playlistId: 1 }); + const updatedDevice = makeDevice({ playlistId: 2, refreshPending: true }); + + mockPrisma.device.findUnique.mockResolvedValue(device); + mockPrisma.device.update.mockResolvedValue(updatedDevice); + + await service.update(1, { playlistId: 2 } as any); + + const updateCall = mockPrisma.device.update.calls[0][0]; + expect(updateCall.data.refreshPending).toBe(true); + }); + + it('should call eventsService.notifyDevicesRefresh on playlist change', async () => { + const device = makeDevice({ playlistId: 1 }); + const updatedDevice = makeDevice({ playlistId: 2 }); + + mockPrisma.device.findUnique.mockResolvedValue(device); + mockPrisma.device.update.mockResolvedValue(updatedDevice); + + await service.update(1, { playlistId: 2 } as any); + + expect(mockEventsService.notifyDevicesRefresh.calls).toHaveLength(1); + expect(mockEventsService.notifyDevicesRefresh.calls[0][0]).toEqual([1]); + }); + + it('should NOT notify when playlist does not change', async () => { + const device = makeDevice({ playlistId: 1 }); + mockPrisma.device.findUnique.mockResolvedValue(device); + mockPrisma.device.update.mockResolvedValue(makeDevice({ name: 'Renamed' })); + + await service.update(1, { name: 'Renamed' } as any); + + expect(mockEventsService.notifyDevicesRefresh.calls).toHaveLength(0); + }); + }); + + // ─── remove ───────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should add MAC to blocked list', async () => { + mockPrisma.device.findUnique.mockResolvedValue(makeDevice()); + mockPrisma.blockedDevice.upsert.mockResolvedValue({}); + mockPrisma.device.delete.mockResolvedValue({}); + + await service.remove(1); + + expect(mockPrisma.blockedDevice.upsert.calls).toHaveLength(1); + expect(mockPrisma.blockedDevice.upsert.calls[0][0].where.macAddress).toBe( + 'AA:BB:CC:DD:EE:FF', + ); + }); + + it('should delete the device', async () => { + mockPrisma.device.findUnique.mockResolvedValue(makeDevice()); + mockPrisma.blockedDevice.upsert.mockResolvedValue({}); + mockPrisma.device.delete.mockResolvedValue({}); + + const result = await service.remove(1); + + expect(mockPrisma.device.delete.calls).toHaveLength(1); + expect(result.message).toBe('Device deleted successfully'); + }); + }); + + // ─── triggerRefresh ───────────────────────────────────────────────── + + describe('triggerRefresh()', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + await expect(service.triggerRefresh(999)).rejects.toThrow(NotFoundException); + }); + + it('should set refreshPending and call notifyDevicesRefresh', async () => { + mockPrisma.device.findUnique.mockResolvedValue(makeDevice()); + mockPrisma.device.update.mockResolvedValue({}); + + const result = await service.triggerRefresh(1); + + expect(mockPrisma.device.update.calls).toHaveLength(1); + expect(mockPrisma.device.update.calls[0][0].data.refreshPending).toBe(true); + expect(mockEventsService.notifyDevicesRefresh.calls).toHaveLength(1); + expect(mockEventsService.notifyDevicesRefresh.calls[0][0]).toEqual([1]); + expect(result.message).toBe('Device refresh triggered'); + }); + }); + + // ─── unassignPlaylist ─────────────────────────────────────────────── + + describe('unassignPlaylist()', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + await expect(service.unassignPlaylist(999)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when no playlist assigned', async () => { + mockPrisma.device.findUnique.mockResolvedValue( + makeDevice({ playlistId: null, playlist: null }), + ); + + await expect(service.unassignPlaylist(1)).rejects.toThrow(BadRequestException); + }); + + it('should unassign playlist and trigger refresh', async () => { + const device = makeDevice({ + playlistId: 5, + playlist: { id: 5, name: 'My Playlist' }, + }); + + mockPrisma.device.findUnique.mockResolvedValue(device); + mockPrisma.device.update.mockResolvedValue( + makeDevice({ playlistId: null, playlist: null }), + ); + + const result = await service.unassignPlaylist(1); + + expect(mockPrisma.device.update.calls[0][0].data.playlistId).toBeNull(); + expect(mockPrisma.device.update.calls[0][0].data.refreshPending).toBe(true); + expect(mockEventsService.notifyDevicesRefresh.calls).toHaveLength(1); + expect(result.message).toBe('Playlist unassigned successfully'); + }); + }); + + // ─── getDisplayContent ────────────────────────────────────────────── + + describe('getDisplayContent()', () => { + it('should throw NotFoundException when device not found', async () => { + mockPrisma.device.findUnique.mockResolvedValue(null); + await expect(service.getDisplayContent('bad-key')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw ForbiddenException when device is inactive', async () => { + mockPrisma.device.findUnique.mockResolvedValue( + makeDevice({ isActive: false }), + ); + + await expect(service.getDisplayContent('test-api-key')).rejects.toThrow( + ForbiddenException, + ); + }); + }); +}); diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts new file mode 100644 index 0000000..57772b3 --- /dev/null +++ b/backend/src/devices/devices.service.ts @@ -0,0 +1,474 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EventsService } from '../events/events.service'; +import { CreateDeviceDto } from './dto/create-device.dto'; +import { UpdateDeviceDto } from './dto/update-device.dto'; +import { generateToken } from '../common/utils/crypto.util'; +import { wrapPaginatedResponse } from '../common/utils/response.util'; +import { serializeDevice, serializeDevices } from './entities/device.entity'; + +@Injectable() +export class DevicesService { + private readonly logger = new Logger(DevicesService.name); + + constructor( + private prisma: PrismaService, + private eventsService: EventsService, + ) {} + + /** + * Create a new device with auto-generated API key + * If MAC was previously blocked (deleted device), unblock it first + */ + async create(createDeviceDto: CreateDeviceDto) { + // Check if MAC address already exists + const existingDevice = await this.prisma.device.findUnique({ + where: { macAddress: createDeviceDto.macAddress }, + }); + + if (existingDevice) { + throw new BadRequestException('Device with this MAC address already exists'); + } + + // Remove from blocked list if exists (allows re-adding deleted devices) + await this.prisma.blockedDevice.deleteMany({ + where: { macAddress: createDeviceDto.macAddress }, + }); + + // Generate unique API key + const apiKey = this.generateApiKey(); + + // Create device + const device = await this.prisma.device.create({ + data: { + name: createDeviceDto.name, + macAddress: createDeviceDto.macAddress, + apiKey, + modelId: createDeviceDto.modelId, + playlistId: createDeviceDto.playlistId, + }, + include: { + model: true, + playlist: { + include: { + items: { + include: { + screen: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }, + }, + }); + + this.logger.log(`Device created: ${device.name} (${device.macAddress})`); + + return serializeDevice(device); + } + + /** + * Find all devices with pagination + */ + async findAll(page = 1, limit = 20) { + const skip = (page - 1) * limit; + + const [devices, total] = await Promise.all([ + this.prisma.device.findMany({ + include: { + model: true, + playlist: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + }), + this.prisma.device.count(), + ]); + + return wrapPaginatedResponse(serializeDevices(devices), total, page, limit); + } + + /** + * Find one device by ID + */ + async findOne(id: number) { + const device = await this.prisma.device.findUnique({ + where: { id }, + include: { + model: true, + playlist: { + include: { + items: { + include: { + screen: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + return serializeDevice(device); + } + + /** + * Update device + */ + async update(id: number, updateDeviceDto: UpdateDeviceDto) { + const device = await this.prisma.device.findUnique({ + where: { id }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Check if MAC address is being changed to an existing one + if (updateDeviceDto.macAddress && updateDeviceDto.macAddress !== device.macAddress) { + const existingDevice = await this.prisma.device.findUnique({ + where: { macAddress: updateDeviceDto.macAddress }, + }); + + if (existingDevice) { + throw new BadRequestException('Device with this MAC address already exists'); + } + } + + // Check if playlist is being changed - trigger device refresh + const playlistChanging = updateDeviceDto.playlistId !== undefined && + updateDeviceDto.playlistId !== device.playlistId; + + const updatedDevice = await this.prisma.device.update({ + where: { id }, + data: { + name: updateDeviceDto.name, + macAddress: updateDeviceDto.macAddress, + firmwareVersion: updateDeviceDto.firmwareVersion, + modelId: updateDeviceDto.modelId, + playlistId: updateDeviceDto.playlistId, + isActive: updateDeviceDto.isActive, + // Set refreshPending if playlist changed to trigger immediate device refresh + ...(playlistChanging && { refreshPending: true }), + }, + include: { + model: true, + playlist: { + include: { + items: { + include: { + screen: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }, + }, + }); + + this.logger.log(`Device updated: ${updatedDevice.name}`); + + // Notify device to refresh if playlist changed (including unassigned) + if (playlistChanging) { + await this.eventsService.notifyDevicesRefresh([id]); + this.logger.log(`Device ${id} playlist changed - refresh notification sent`); + } + + return serializeDevice(updatedDevice); + } + + /** + * Delete device + * Adds MAC address to blocked_devices to prevent auto-re-provisioning + */ + async remove(id: number) { + const device = await this.prisma.device.findUnique({ + where: { id }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Add MAC to blocked devices to prevent auto-re-provisioning + // Device will receive reset_firmware: true on next /api/setup call + await this.prisma.blockedDevice.upsert({ + where: { macAddress: device.macAddress }, + create: { macAddress: device.macAddress, reason: 'Deleted by admin' }, + update: { createdAt: new Date() }, + }); + + await this.prisma.device.delete({ + where: { id }, + }); + + this.logger.log(`Device deleted and blocked: ${device.name} (${device.macAddress})`); + + return { message: 'Device deleted successfully' }; + } + + /** + * Device polling endpoint - returns current screen to display + * This is called by the device using its API key + */ + async getDisplayContent(apiKey: string) { + const device = await this.prisma.device.findUnique({ + where: { apiKey }, + include: { + model: true, + playlist: { + include: { + items: { + include: { + screen: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + if (!device.isActive) { + throw new ForbiddenException('Device is inactive'); + } + + // Update last seen timestamp + await this.prisma.device.update({ + where: { id: device.id }, + data: { lastSeenAt: new Date() }, + }); + + // If no playlist assigned, return default "Hello World" welcome screen + if (!device.playlist || !device.playlist.items.length) { + return { + deviceId: device.id, + deviceName: device.name, + message: 'No playlist assigned - showing default welcome screen', + screen: null, + defaultContent: { + type: 'welcome', + title: 'Hello World', + subtitle: 'This is inker!', + message: 'Assign a playlist to this device to display your content', + }, + refreshRate: device.refreshRate || 900, + }; + } + + // Get current screen from playlist (simple rotation for now) + // In production, you might want more sophisticated scheduling + const currentIndex = Math.floor(Date.now() / 60000) % device.playlist.items.length; + const currentItem = device.playlist.items[currentIndex]; + + if (!currentItem.screen) { + throw new NotFoundException('Current playlist item has no screen'); + } + + this.logger.debug( + `Device ${device.name} polling - serving screen: ${currentItem.screen.name}`, + ); + + return { + deviceId: device.id, + deviceName: device.name, + screen: { + id: currentItem.screen.id, + name: currentItem.screen.name, + imageUrl: currentItem.screen.imageUrl, + duration: currentItem.duration, + }, + nextRefresh: currentItem.duration, + }; + } + + /** + * Regenerate API key for a device + */ + async regenerateApiKey(id: number) { + const device = await this.prisma.device.findUnique({ + where: { id }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + const newApiKey = this.generateApiKey(); + const updatedDevice = await this.prisma.device.update({ + where: { id }, + data: { apiKey: newApiKey }, + }); + + this.logger.log(`API key regenerated for device: ${device.name}`); + + return { + deviceId: updatedDevice.id, + apiKey: updatedDevice.apiKey, + }; + } + + /** + * Generate a unique API key + */ + private generateApiKey(): string { + return generateToken(32); + } + + /** + * Log device event + */ + async logDeviceEvent( + deviceId: number, + level: string, + message: string, + metadata?: any, + ) { + return this.prisma.deviceLog.create({ + data: { + deviceId, + level, + message, + metadata: metadata || {}, + }, + }); + } + + /** + * Get device logs + */ + async getDeviceLogs(deviceId: number) { + const device = await this.prisma.device.findUnique({ + where: { id: deviceId }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + return this.prisma.deviceLog.findMany({ + where: { deviceId }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + } + + /** + * Trigger device refresh + * Sets refreshPending flag and emits SSE event to notify connected clients + */ + async triggerRefresh(id: number) { + const device = await this.prisma.device.findUnique({ + where: { id }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Set refreshPending flag + await this.prisma.device.update({ + where: { id }, + data: { refreshPending: true }, + }); + + // Emit SSE event for connected clients + await this.eventsService.notifyDevicesRefresh([id]); + + this.logger.log(`Refresh triggered for device: ${device.name}`); + + return { message: 'Device refresh triggered', deviceId: id }; + } + + /** + * Unassign playlist from device + * Device will display the default "Hello World" welcome screen + */ + async unassignPlaylist(id: number) { + const device = await this.prisma.device.findUnique({ + where: { id }, + include: { + playlist: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Check if device has a playlist assigned + if (!device.playlistId) { + throw new BadRequestException('Device has no playlist assigned'); + } + + const previousPlaylist = device.playlist; + + // Unassign playlist and trigger refresh + const updatedDevice = await this.prisma.device.update({ + where: { id }, + data: { + playlistId: null, + refreshPending: true, // Trigger refresh to show default screen + }, + include: { + model: true, + }, + }); + + // Notify device to refresh (will now show the default welcome screen) + await this.eventsService.notifyDevicesRefresh([id]); + + this.logger.log( + `Playlist "${previousPlaylist?.name}" unassigned from device "${device.name}" - device will show default welcome screen`, + ); + + return { + message: 'Playlist unassigned successfully', + device: serializeDevice(updatedDevice), + previousPlaylist: previousPlaylist + ? { id: previousPlaylist.id, name: previousPlaylist.name } + : null, + displayContent: { + type: 'welcome', + title: 'Hello World', + subtitle: 'This is inker!', + message: 'Assign a playlist to this device to display your content', + }, + }; + } +} diff --git a/backend/src/devices/dto/create-device.dto.ts b/backend/src/devices/dto/create-device.dto.ts new file mode 100644 index 0000000..1548b71 --- /dev/null +++ b/backend/src/devices/dto/create-device.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsInt, Matches, MaxLength, MinLength } from 'class-validator'; + +export class CreateDeviceDto { + @ApiProperty({ + example: 'Living Room Display', + description: 'Device name', + }) + @IsString() + @MinLength(1) + @MaxLength(255) + name: string; + + @ApiProperty({ + example: 'AA:BB:CC:DD:EE:FF', + description: 'Device MAC address', + }) + @IsString() + @Matches(/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/, { + message: 'Invalid MAC address format', + }) + macAddress: string; + + @ApiPropertyOptional({ + example: 1, + description: 'Model ID', + }) + @IsOptional() + @IsInt() + modelId?: number; + + @ApiPropertyOptional({ + example: 1, + description: 'Playlist ID to assign', + }) + @IsOptional() + @IsInt() + playlistId?: number; +} diff --git a/backend/src/devices/dto/update-device.dto.ts b/backend/src/devices/dto/update-device.dto.ts new file mode 100644 index 0000000..faf671a --- /dev/null +++ b/backend/src/devices/dto/update-device.dto.ts @@ -0,0 +1,59 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsInt, IsBoolean, Matches, ValidateIf, MaxLength } from 'class-validator'; + +export class UpdateDeviceDto { + @ApiPropertyOptional({ + example: 'Living Room Display', + description: 'Device name', + }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ + example: 'AA:BB:CC:DD:EE:FF', + description: 'Device MAC address', + }) + @IsOptional() + @IsString() + @Matches(/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/, { + message: 'Invalid MAC address format', + }) + macAddress?: string; + + @ApiPropertyOptional({ + example: '1.0.5', + description: 'Firmware version', + }) + @IsOptional() + @IsString() + @MaxLength(50) + firmwareVersion?: string; + + @ApiPropertyOptional({ + example: 1, + description: 'Model ID', + }) + @IsOptional() + @IsInt() + modelId?: number; + + @ApiPropertyOptional({ + example: 1, + description: 'Playlist ID (set to null to unassign)', + nullable: true, + }) + @IsOptional() + @ValidateIf((object, value) => value !== null) + @IsInt() + playlistId?: number | null; + + @ApiPropertyOptional({ + example: true, + description: 'Device active status', + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/devices/entities/device.entity.test.ts b/backend/src/devices/entities/device.entity.test.ts new file mode 100644 index 0000000..a63f6d8 --- /dev/null +++ b/backend/src/devices/entities/device.entity.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + isDeviceOnline, + calculateDeviceStatus, + getOfflineThresholdMs, + serializeDevice, + serializeDevices, +} from './device.entity'; + +describe('device.entity', () => { + const originalEnv = process.env.DEVICE_OFFLINE_THRESHOLD; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.DEVICE_OFFLINE_THRESHOLD; + } else { + process.env.DEVICE_OFFLINE_THRESHOLD = originalEnv; + } + }); + + describe('getOfflineThresholdMs', () => { + it('should return 5 minutes by default', () => { + delete process.env.DEVICE_OFFLINE_THRESHOLD; + expect(getOfflineThresholdMs()).toBe(300000); + }); + + it('should use environment variable when set', () => { + process.env.DEVICE_OFFLINE_THRESHOLD = '60000'; + expect(getOfflineThresholdMs()).toBe(60000); + }); + + it('should fall back to default for invalid env value', () => { + process.env.DEVICE_OFFLINE_THRESHOLD = 'abc'; + expect(getOfflineThresholdMs()).toBe(300000); + }); + + it('should fall back to default for negative value', () => { + process.env.DEVICE_OFFLINE_THRESHOLD = '-1'; + expect(getOfflineThresholdMs()).toBe(300000); + }); + }); + + describe('isDeviceOnline', () => { + it('should return false when device is not active', () => { + expect(isDeviceOnline({ isActive: false, lastSeenAt: new Date() })).toBe(false); + }); + + it('should return false when lastSeenAt is null', () => { + expect(isDeviceOnline({ isActive: true, lastSeenAt: null })).toBe(false); + }); + + it('should return true when seen recently', () => { + const recentDate = new Date(Date.now() - 60000); // 1 min ago + expect(isDeviceOnline({ isActive: true, lastSeenAt: recentDate })).toBe(true); + }); + + it('should return false when seen too long ago', () => { + const staleDate = new Date(Date.now() - 600000); // 10 min ago + expect(isDeviceOnline({ isActive: true, lastSeenAt: staleDate })).toBe(false); + }); + }); + + describe('calculateDeviceStatus', () => { + it('should return online for active recent device', () => { + expect(calculateDeviceStatus({ isActive: true, lastSeenAt: new Date() })).toBe('online'); + }); + + it('should return offline for inactive device', () => { + expect(calculateDeviceStatus({ isActive: false, lastSeenAt: new Date() })).toBe('offline'); + }); + }); + + describe('serializeDevice', () => { + it('should add status and isOnline fields', () => { + const device = { id: 1, isActive: true, lastSeenAt: new Date() }; + const result = serializeDevice(device); + expect(result.status).toBe('online'); + expect(result.isOnline).toBe(true); + expect(result.id).toBe(1); + }); + }); + + describe('serializeDevices', () => { + it('should serialize array of devices', () => { + const devices = [ + { id: 1, isActive: true, lastSeenAt: new Date() }, + { id: 2, isActive: false, lastSeenAt: null }, + ]; + const result = serializeDevices(devices); + expect(result).toHaveLength(2); + expect(result[0].isOnline).toBe(true); + expect(result[1].isOnline).toBe(false); + }); + }); +}); diff --git a/backend/src/devices/entities/device.entity.ts b/backend/src/devices/entities/device.entity.ts new file mode 100644 index 0000000..95834f1 --- /dev/null +++ b/backend/src/devices/entities/device.entity.ts @@ -0,0 +1,112 @@ +import { Device as PrismaDevice } from '@prisma/client'; + +/** + * Device entity with computed status and isOnline fields + * + * This serializer adds computed fields based on device activity + * to match frontend expectations (online/offline status) + */ + +export type DeviceStatus = 'online' | 'offline'; + +/** + * Get the offline threshold in milliseconds from environment variable + * Default: 5 minutes (300000ms) + * Configurable via DEVICE_OFFLINE_THRESHOLD environment variable + */ +export function getOfflineThresholdMs(): number { + const envValue = process.env.DEVICE_OFFLINE_THRESHOLD; + if (envValue) { + const parsed = parseInt(envValue, 10); + if (!isNaN(parsed) && parsed > 0) { + return parsed; + } + } + // Default: 5 minutes + return 5 * 60 * 1000; +} + +/** + * Check if a device is online based on lastSeenAt timestamp + * + * A device is considered online if: + * - isActive is true AND + * - lastSeenAt is within the offline threshold (default: 5 minutes) + * + * @param device - Device from database + * @returns true if device is online, false otherwise + */ +export function isDeviceOnline(device: { + isActive: boolean; + lastSeenAt: Date | null; +}): boolean { + // Device is offline if not active + if (!device.isActive) { + return false; + } + + // Device is offline if never seen + if (!device.lastSeenAt) { + return false; + } + + // Check if device was seen recently (within threshold) + const thresholdMs = getOfflineThresholdMs(); + const thresholdTime = new Date(Date.now() - thresholdMs); + return device.lastSeenAt > thresholdTime; +} + +/** + * Calculate device status based on lastSeenAt timestamp and isActive flag + * + * A device is considered: + * - 'offline': if isActive is false OR lastSeenAt is older than threshold + * - 'online': if isActive is true AND lastSeenAt is within the threshold + * + * @param device - Device from database + * @returns 'online' or 'offline' status + */ +export function calculateDeviceStatus(device: { + isActive: boolean; + lastSeenAt: Date | null; +}): DeviceStatus { + return isDeviceOnline(device) ? 'online' : 'offline'; +} + +/** + * Serialized device type with computed fields + */ +export type SerializedDevice = T & { + status: DeviceStatus; + isOnline: boolean; +}; + +/** + * Serialize device with computed status and isOnline fields + * + * @param device - Device with relations from Prisma + * @returns Device object with added 'status' and 'isOnline' fields + */ +export function serializeDevice( + device: T, +): SerializedDevice> { + const online = isDeviceOnline(device); + const { apiKey, ...rest } = device as T & { apiKey?: string }; + return { + ...rest, + status: online ? 'online' : 'offline', + isOnline: online, + }; +} + +/** + * Serialize array of devices with computed status and isOnline fields + * + * @param devices - Array of devices from Prisma + * @returns Array of devices with added 'status' and 'isOnline' fields + */ +export function serializeDevices( + devices: T[], +): Array>> { + return devices.map(serializeDevice); +} diff --git a/backend/src/events/events.controller.ts b/backend/src/events/events.controller.ts new file mode 100644 index 0000000..63f3fa3 --- /dev/null +++ b/backend/src/events/events.controller.ts @@ -0,0 +1,108 @@ +import { + Controller, + Get, + Post, + Body, + Sse, + Query, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { Observable, interval, map, merge } from 'rxjs'; +import { Public } from '../common/decorators/public.decorator'; +import { EventsService, DeviceEvent } from './events.service'; +import { PinAuthService } from '../auth/pin-auth.service'; + +interface MessageEvent { + data: string; +} + +@Controller('events') +export class EventsController { + private readonly logger = new Logger(EventsController.name); + + constructor( + private eventsService: EventsService, + private pinAuthService: PinAuthService, + ) {} + + /** + * SSE endpoint for real-time device update notifications + * Clients connect here to receive push notifications when content changes + * + * Note: SSE doesn't support custom headers, so we use query param for auth + */ + @Public() + @Get('stream') + @Sse() + deviceUpdates(@Query('token') token: string): Observable { + // Validate session token from query parameter + if (!token || token.length > 128) { + throw new UnauthorizedException('Authentication token required'); + } + + if (!this.pinAuthService.validateSession(token)) { + this.logger.warn('Invalid SSE authentication token'); + throw new UnauthorizedException('Invalid authentication token'); + } + + this.logger.log('New SSE client connected'); + + // Send heartbeat every 30 seconds to keep connection alive + const heartbeat$ = interval(30000).pipe( + map(() => ({ + data: JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }), + })), + ); + + // Stream of actual events + const events$ = this.eventsService.getEventStream().pipe( + map((event: DeviceEvent) => ({ + data: JSON.stringify(event), + })), + ); + + // Merge heartbeat and events + return merge(heartbeat$, events$); + } + + /** + * Endpoint to manually trigger device notifications + * Used by frontend after mutations + * Protected by global PinAuthGuard + */ + @Post('notify') + async notify( + @Body() + body: { + type: 'screen' | 'playlist' | 'screen_design' | 'devices'; + id?: number; + deviceIds?: number[]; + }, + ) { + switch (body.type) { + case 'screen': + if (body.id) { + await this.eventsService.notifyScreenUpdate(body.id); + } + break; + case 'playlist': + if (body.id) { + await this.eventsService.notifyPlaylistUpdate(body.id); + } + break; + case 'screen_design': + if (body.id) { + await this.eventsService.notifyScreenDesignUpdate(body.id); + } + break; + case 'devices': + if (body.deviceIds && body.deviceIds.length > 0) { + await this.eventsService.notifyDevicesRefresh(body.deviceIds); + } + break; + } + + return { success: true }; + } +} diff --git a/backend/src/events/events.module.ts b/backend/src/events/events.module.ts new file mode 100644 index 0000000..60c1876 --- /dev/null +++ b/backend/src/events/events.module.ts @@ -0,0 +1,13 @@ +import { Module, Global } from '@nestjs/common'; +import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; +import { AuthModule } from '../auth/auth.module'; + +@Global() +@Module({ + imports: [AuthModule], + controllers: [EventsController], + providers: [EventsService], + exports: [EventsService], +}) +export class EventsModule {} diff --git a/backend/src/events/events.service.test.ts b/backend/src/events/events.service.test.ts new file mode 100644 index 0000000..1006978 --- /dev/null +++ b/backend/src/events/events.service.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { firstValueFrom } from 'rxjs'; +import { EventsService, DeviceEvent } from './events.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; +import { createMock } from '../test/mocks/helpers'; + +describe('EventsService', () => { + let service: EventsService; + let mockPrisma: MockPrisma & { deviceScreenAssignment: any }; + + beforeEach(() => { + mockPrisma = createMockPrisma() as any; + // Add deviceScreenAssignment model (not in default mock) + mockPrisma.deviceScreenAssignment = { + findMany: createMock(), + }; + service = new EventsService(mockPrisma as any); + }); + + // ─── emit / getEventStream ───────────────────────────────────────── + + describe('emit() and getEventStream()', () => { + it('should emit events that can be received via getEventStream', async () => { + const event: DeviceEvent = { + type: 'device:refresh', + payload: { deviceIds: [1], timestamp: Date.now() }, + }; + + const eventPromise = firstValueFrom(service.getEventStream()); + service.emit(event); + + const received = await eventPromise; + expect(received.type).toBe('device:refresh'); + expect(received.payload.deviceIds).toEqual([1]); + }); + }); + + // ─── getEventsForDevices ─────────────────────────────────────────── + + describe('getEventsForDevices()', () => { + it('should filter events by matching deviceIds', async () => { + const stream = service.getEventsForDevices([1, 2]); + + const eventPromise = firstValueFrom(stream); + + service.emit({ + type: 'device:refresh', + payload: { deviceIds: [2, 3], timestamp: Date.now() }, + }); + + const received = await eventPromise; + expect(received.payload.deviceIds).toEqual([2, 3]); + }); + + it('should pass through events with no deviceIds (broadcast)', async () => { + const stream = service.getEventsForDevices([1]); + + const eventPromise = firstValueFrom(stream); + + service.emit({ + type: 'playlist:updated', + payload: { playlistId: 5, timestamp: Date.now() }, + }); + + const received = await eventPromise; + expect(received.type).toBe('playlist:updated'); + }); + + it('should filter out events targeting other devices', (done) => { + const stream = service.getEventsForDevices([1]); + + let receivedCount = 0; + const subscription = stream.subscribe((event) => { + receivedCount++; + // Only the second event should arrive (broadcast) + expect(event.type).toBe('screen:updated'); + subscription.unsubscribe(); + done(); + }); + + // This should be filtered out (device 99 not in [1]) + service.emit({ + type: 'device:refresh', + payload: { deviceIds: [99], timestamp: Date.now() }, + }); + + // This should pass through (no deviceIds = broadcast) + service.emit({ + type: 'screen:updated', + payload: { screenId: 1, timestamp: Date.now() }, + }); + }); + }); + + // ─── notifyScreenUpdate ──────────────────────────────────────────── + + describe('notifyScreenUpdate()', () => { + it('should find playlist items, collect device IDs, set refreshPending, and emit', async () => { + mockPrisma.playlistItem.findMany.mockResolvedValue([ + { + playlist: { + devices: [{ id: 10 }, { id: 20 }], + }, + }, + { + playlist: { + devices: [{ id: 20 }, { id: 30 }], + }, + }, + ]); + mockPrisma.device.updateMany.mockResolvedValue({ count: 3 }); + + const eventPromise = firstValueFrom(service.getEventStream()); + + await service.notifyScreenUpdate(5); + + // Should deduplicate device IDs + expect(mockPrisma.device.updateMany.calls).toHaveLength(1); + const updateCall = mockPrisma.device.updateMany.calls[0][0]; + expect(updateCall.where.id.in).toContain(10); + expect(updateCall.where.id.in).toContain(20); + expect(updateCall.where.id.in).toContain(30); + expect(updateCall.data.refreshPending).toBe(true); + + const event = await eventPromise; + expect(event.type).toBe('screen:updated'); + expect(event.payload.screenId).toBe(5); + }); + + it('should emit event even when no devices are affected', async () => { + mockPrisma.playlistItem.findMany.mockResolvedValue([]); + + const eventPromise = firstValueFrom(service.getEventStream()); + + await service.notifyScreenUpdate(5); + + // Should NOT call updateMany when no devices + expect(mockPrisma.device.updateMany.calls).toHaveLength(0); + + const event = await eventPromise; + expect(event.type).toBe('screen:updated'); + expect(event.payload.deviceIds).toEqual([]); + }); + }); + + // ─── notifyPlaylistUpdate ────────────────────────────────────────── + + describe('notifyPlaylistUpdate()', () => { + it('should find devices, set refreshPending, and emit event', async () => { + mockPrisma.device.findMany.mockResolvedValue([{ id: 1 }, { id: 2 }]); + mockPrisma.device.updateMany.mockResolvedValue({ count: 2 }); + + const eventPromise = firstValueFrom(service.getEventStream()); + + await service.notifyPlaylistUpdate(7); + + expect(mockPrisma.device.findMany.calls).toHaveLength(1); + expect(mockPrisma.device.findMany.calls[0][0].where.playlistId).toBe(7); + expect(mockPrisma.device.updateMany.calls).toHaveLength(1); + expect(mockPrisma.device.updateMany.calls[0][0].data.refreshPending).toBe(true); + + const event = await eventPromise; + expect(event.type).toBe('playlist:updated'); + expect(event.payload.playlistId).toBe(7); + expect(event.payload.deviceIds).toEqual([1, 2]); + }); + + it('should emit event even with no devices on playlist', async () => { + mockPrisma.device.findMany.mockResolvedValue([]); + + const eventPromise = firstValueFrom(service.getEventStream()); + + await service.notifyPlaylistUpdate(7); + + expect(mockPrisma.device.updateMany.calls).toHaveLength(0); + + const event = await eventPromise; + expect(event.type).toBe('playlist:updated'); + expect(event.payload.deviceIds).toEqual([]); + }); + }); + + // ─── notifyDevicesRefresh ────────────────────────────────────────── + + describe('notifyDevicesRefresh()', () => { + it('should NOT call updateMany for empty device array', async () => { + const eventPromise = firstValueFrom(service.getEventStream()); + + await service.notifyDevicesRefresh([]); + + expect(mockPrisma.device.updateMany.calls).toHaveLength(0); + + const event = await eventPromise; + expect(event.type).toBe('device:refresh'); + expect(event.payload.deviceIds).toEqual([]); + }); + + it('should set refreshPending and emit event for non-empty array', async () => { + mockPrisma.device.updateMany.mockResolvedValue({ count: 2 }); + + const eventPromise = firstValueFrom(service.getEventStream()); + + await service.notifyDevicesRefresh([5, 10]); + + expect(mockPrisma.device.updateMany.calls).toHaveLength(1); + expect(mockPrisma.device.updateMany.calls[0][0].where.id.in).toEqual([5, 10]); + expect(mockPrisma.device.updateMany.calls[0][0].data.refreshPending).toBe(true); + + const event = await eventPromise; + expect(event.type).toBe('device:refresh'); + expect(event.payload.deviceIds).toEqual([5, 10]); + }); + }); +}); diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts new file mode 100644 index 0000000..2f2c6c4 --- /dev/null +++ b/backend/src/events/events.service.ts @@ -0,0 +1,251 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { Subject, Observable, filter } from 'rxjs'; +import { PrismaService } from '../prisma/prisma.service'; + +export type DeviceEventType = + | 'screen:updated' + | 'screen:deleted' + | 'playlist:updated' + | 'playlist:deleted' + | 'screen_design:updated' + | 'screen_design:deleted' + | 'device:refresh'; + +export interface DeviceEvent { + type: DeviceEventType; + payload: { + id?: number; + deviceIds?: number[]; + playlistId?: number; + screenId?: number; + screenDesignId?: number; + timestamp: number; + }; +} + +@Injectable() +export class EventsService implements OnModuleDestroy { + private readonly logger = new Logger(EventsService.name); + private events$ = new Subject(); + + constructor(private prisma: PrismaService) {} + + /** + * Cleanup the Subject when the module is destroyed to prevent memory leaks + */ + onModuleDestroy() { + this.events$.complete(); + } + + /** + * Emit an event to all connected clients + */ + emit(event: DeviceEvent): void { + this.logger.debug(`Emitting event: ${event.type}`, event.payload); + this.events$.next(event); + } + + /** + * Get observable stream of events + */ + getEventStream(): Observable { + return this.events$.asObservable(); + } + + /** + * Get events filtered for specific device IDs + */ + getEventsForDevices(deviceIds: number[]): Observable { + return this.events$.pipe( + filter((event) => { + // If event has specific deviceIds, check if any match + if (event.payload.deviceIds && event.payload.deviceIds.length > 0) { + return event.payload.deviceIds.some((id) => deviceIds.includes(id)); + } + // Otherwise, pass all events (client will filter based on their playlists/screens) + return true; + }), + ); + } + + /** + * Notify devices when a screen is updated + * Finds all devices that have this screen in their playlist + */ + async notifyScreenUpdate(screenId: number): Promise { + // Find all playlists containing this screen + const playlistItems = await this.prisma.playlistItem.findMany({ + where: { screenId }, + include: { + playlist: { + include: { + devices: { + select: { id: true }, + }, + }, + }, + }, + }); + + // Collect all device IDs that need to be notified + const deviceIds = new Set(); + for (const item of playlistItems) { + for (const device of item.playlist.devices) { + deviceIds.add(device.id); + } + } + + const deviceIdsArray = Array.from(deviceIds); + + if (deviceIdsArray.length > 0) { + // Set refreshPending flag on all affected devices + await this.prisma.device.updateMany({ + where: { id: { in: deviceIdsArray } }, + data: { refreshPending: true }, + }); + + this.logger.log( + `Screen ${screenId} updated - notifying ${deviceIdsArray.length} devices`, + ); + } + + this.emit({ + type: 'screen:updated', + payload: { + screenId, + deviceIds: deviceIdsArray, + timestamp: Date.now(), + }, + }); + } + + /** + * Notify devices when a playlist is updated + */ + async notifyPlaylistUpdate(playlistId: number): Promise { + // Find all devices using this playlist + const devices = await this.prisma.device.findMany({ + where: { playlistId }, + select: { id: true }, + }); + + const deviceIds = devices.map((d) => d.id); + + if (deviceIds.length > 0) { + // Set refreshPending flag on all affected devices + await this.prisma.device.updateMany({ + where: { id: { in: deviceIds } }, + data: { refreshPending: true }, + }); + + this.logger.log( + `Playlist ${playlistId} updated - notifying ${deviceIds.length} devices`, + ); + } + + this.emit({ + type: 'playlist:updated', + payload: { + playlistId, + deviceIds, + timestamp: Date.now(), + }, + }); + } + + /** + * Notify devices when a screen design is updated + * @returns The number of devices that were notified + */ + async notifyScreenDesignUpdate(screenDesignId: number): Promise { + // Find all playlists containing this screen design + const playlistItems = await this.prisma.playlistItem.findMany({ + where: { screenDesignId }, + include: { + playlist: { + include: { + devices: { + select: { id: true }, + }, + }, + }, + }, + }); + + // Collect all device IDs + const deviceIds = new Set(); + for (const item of playlistItems) { + for (const device of item.playlist.devices) { + deviceIds.add(device.id); + } + } + + // Also find devices with direct screen design assignments + const directAssignments = await this.prisma.deviceScreenAssignment.findMany({ + where: { screenDesignId }, + select: { deviceId: true }, + }); + + for (const assignment of directAssignments) { + deviceIds.add(assignment.deviceId); + } + + const deviceIdsArray = Array.from(deviceIds); + + if (deviceIdsArray.length > 0) { + // Set refreshPending flag on all affected devices + await this.prisma.device.updateMany({ + where: { id: { in: deviceIdsArray } }, + data: { refreshPending: true }, + }); + + this.logger.log( + `Screen design ${screenDesignId} updated - notifying ${deviceIdsArray.length} devices`, + ); + + // Also emit device:refresh event (same as individual device refresh button) + // This ensures the frontend dashboard updates and the behavior is consistent + this.emit({ + type: 'device:refresh', + payload: { + deviceIds: deviceIdsArray, + timestamp: Date.now(), + }, + }); + } + + this.emit({ + type: 'screen_design:updated', + payload: { + screenDesignId, + deviceIds: deviceIdsArray, + timestamp: Date.now(), + }, + }); + + return deviceIdsArray.length; + } + + /** + * Notify specific devices to refresh + */ + async notifyDevicesRefresh(deviceIds: number[]): Promise { + if (deviceIds.length > 0) { + // Set refreshPending flag on specified devices + await this.prisma.device.updateMany({ + where: { id: { in: deviceIds } }, + data: { refreshPending: true }, + }); + + this.logger.log(`Notifying ${deviceIds.length} devices to refresh`); + } + + this.emit({ + type: 'device:refresh', + payload: { + deviceIds, + timestamp: Date.now(), + }, + }); + } +} diff --git a/backend/src/extensions/dto/create-extension.dto.ts b/backend/src/extensions/dto/create-extension.dto.ts new file mode 100644 index 0000000..7302f5c --- /dev/null +++ b/backend/src/extensions/dto/create-extension.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsIn, IsObject } from 'class-validator'; + +export class CreateExtensionDto { + @ApiProperty({ + example: 'Weather Plugin', + description: 'Extension name', + }) + @IsString() + name: string; + + @ApiPropertyOptional({ + example: 'Displays weather information', + description: 'Extension description', + }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + example: 'webhook', + description: 'Extension type', + enum: ['webhook', 'polling', 'custom'], + }) + @IsString() + @IsIn(['webhook', 'polling', 'custom']) + type: string; + + @ApiPropertyOptional({ + example: { apiKey: 'xxx', endpoint: 'https://api.example.com' }, + description: 'Extension configuration (JSON)', + }) + @IsOptional() + @IsObject() + config?: any; + + @ApiPropertyOptional({ + example: true, + description: 'Extension active status', + default: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/extensions/dto/update-extension.dto.ts b/backend/src/extensions/dto/update-extension.dto.ts new file mode 100644 index 0000000..3e5a8a3 --- /dev/null +++ b/backend/src/extensions/dto/update-extension.dto.ts @@ -0,0 +1,46 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsIn, IsObject } from 'class-validator'; + +export class UpdateExtensionDto { + @ApiPropertyOptional({ + example: 'Weather Plugin', + description: 'Extension name', + }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ + example: 'Displays weather information', + description: 'Extension description', + }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + example: 'webhook', + description: 'Extension type', + enum: ['webhook', 'polling', 'custom'], + }) + @IsOptional() + @IsString() + @IsIn(['webhook', 'polling', 'custom']) + type?: string; + + @ApiPropertyOptional({ + example: { apiKey: 'xxx', endpoint: 'https://api.example.com' }, + description: 'Extension configuration (JSON)', + }) + @IsOptional() + @IsObject() + config?: any; + + @ApiPropertyOptional({ + example: true, + description: 'Extension active status', + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/extensions/extensions.controller.test.ts b/backend/src/extensions/extensions.controller.test.ts new file mode 100644 index 0000000..0b6f71d --- /dev/null +++ b/backend/src/extensions/extensions.controller.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { ExtensionsController } from './extensions.controller'; +import { ExtensionsService } from './extensions.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('ExtensionsController (e2e)', () => { + let app: INestApplication; + + const mockExtension = { + id: 1, + name: 'Test Extension', + description: 'A test extension', + type: 'webhook', + config: {}, + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockExtensionsService = { + create: async () => mockExtension, + findAll: async () => ({ items: [mockExtension], total: 1 }), + findOne: async () => mockExtension, + update: async () => ({ ...mockExtension, name: 'Updated Extension' }), + remove: async () => ({ message: 'Extension deleted successfully' }), + toggleActive: async () => ({ ...mockExtension, isActive: false }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [ExtensionsController], + providers: [ + { provide: ExtensionsService, useValue: mockExtensionsService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /extensions', () => { + it('should return list of extensions', async () => { + const response = await request(app.getHttpServer()) + .get('/extensions') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + }); + }); + + describe('POST /extensions', () => { + it('should create a new extension', async () => { + const response = await request(app.getHttpServer()) + .post('/extensions') + .send({ name: 'New Extension', type: 'webhook' }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('name'); + }); + }); + + describe('DELETE /extensions/:id', () => { + it('should delete an extension', async () => { + const response = await request(app.getHttpServer()) + .delete('/extensions/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Extension deleted successfully'); + }); + }); +}); diff --git a/backend/src/extensions/extensions.controller.ts b/backend/src/extensions/extensions.controller.ts new file mode 100644 index 0000000..fa54e56 --- /dev/null +++ b/backend/src/extensions/extensions.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { ExtensionsService } from './extensions.service'; +import { CreateExtensionDto } from './dto/create-extension.dto'; +import { UpdateExtensionDto } from './dto/update-extension.dto'; + +@ApiTags('extensions') +@ApiBearerAuth('access-token') +@Controller('extensions') +export class ExtensionsController { + constructor(private readonly extensionsService: ExtensionsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new extension' }) + @ApiResponse({ status: 201, description: 'Extension successfully created' }) + create(@Body() createExtensionDto: CreateExtensionDto) { + return this.extensionsService.create(createExtensionDto); + } + + @Get() + @ApiOperation({ summary: 'Get all extensions' }) + @ApiResponse({ status: 200, description: 'List of extensions' }) + @ApiQuery({ + name: 'activeOnly', + required: false, + type: Boolean, + description: 'Filter to active extensions only', + }) + findAll(@Query('activeOnly') activeOnly?: string) { + return this.extensionsService.findAll(activeOnly === 'true'); + } + + @Get(':id') + @ApiOperation({ summary: 'Get extension by ID' }) + @ApiResponse({ status: 200, description: 'Extension details' }) + @ApiResponse({ status: 404, description: 'Extension not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.extensionsService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update extension' }) + @ApiResponse({ status: 200, description: 'Extension successfully updated' }) + @ApiResponse({ status: 404, description: 'Extension not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateExtensionDto: UpdateExtensionDto, + ) { + return this.extensionsService.update(id, updateExtensionDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete extension' }) + @ApiResponse({ status: 200, description: 'Extension successfully deleted' }) + @ApiResponse({ status: 404, description: 'Extension not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.extensionsService.remove(id); + } + + @Post(':id/toggle') + @ApiOperation({ summary: 'Toggle extension active status' }) + @ApiResponse({ status: 200, description: 'Extension status toggled' }) + @ApiResponse({ status: 404, description: 'Extension not found' }) + toggleActive(@Param('id', ParseIntPipe) id: number) { + return this.extensionsService.toggleActive(id); + } +} diff --git a/backend/src/extensions/extensions.module.ts b/backend/src/extensions/extensions.module.ts new file mode 100644 index 0000000..99ef942 --- /dev/null +++ b/backend/src/extensions/extensions.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ExtensionsController } from './extensions.controller'; +import { ExtensionsService } from './extensions.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ExtensionsController], + providers: [ExtensionsService], + exports: [ExtensionsService], +}) +export class ExtensionsModule {} diff --git a/backend/src/extensions/extensions.service.test.ts b/backend/src/extensions/extensions.service.test.ts new file mode 100644 index 0000000..0dac58e --- /dev/null +++ b/backend/src/extensions/extensions.service.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException } from '@nestjs/common'; +import { ExtensionsService } from './extensions.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; + +describe('ExtensionsService', () => { + let service: ExtensionsService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new ExtensionsService(mockPrisma as any); + }); + + // ─── create ────────────────────────────────────────────────────────── + + describe('create()', () => { + it('should create an extension and return it', async () => { + const dto = { name: 'Weather', type: 'widget', description: 'Weather ext' }; + const created = { id: 1, ...dto, config: {}, isActive: true }; + mockPrisma.extension.create.mockResolvedValue(created); + + const result = await service.create(dto as any); + expect(result).toEqual(created); + expect(mockPrisma.extension.create.calls).toHaveLength(1); + }); + }); + + // ─── findOne ───────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should return extension when found', async () => { + const ext = { id: 1, name: 'Weather', isActive: true }; + mockPrisma.extension.findUnique.mockResolvedValue(ext); + + const result = await service.findOne(1); + expect(result).toEqual(ext); + }); + + it('should throw NotFoundException when not found', async () => { + mockPrisma.extension.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + }); + + // ─── update ────────────────────────────────────────────────────────── + + describe('update()', () => { + it('should throw NotFoundException when extension does not exist', async () => { + mockPrisma.extension.findUnique.mockResolvedValue(null); + await expect(service.update(999, { name: 'X' } as any)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should update extension and return it', async () => { + mockPrisma.extension.findUnique.mockResolvedValue({ id: 1, name: 'Old' }); + const updated = { id: 1, name: 'New', isActive: true }; + mockPrisma.extension.update.mockResolvedValue(updated); + + const result = await service.update(1, { name: 'New' } as any); + expect(result).toEqual(updated); + }); + }); + + // ─── remove ────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should throw NotFoundException when extension does not exist', async () => { + mockPrisma.extension.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should delete extension successfully', async () => { + mockPrisma.extension.findUnique.mockResolvedValue({ id: 1, name: 'Ext' }); + mockPrisma.extension.delete.mockResolvedValue({}); + + const result = await service.remove(1); + expect(result).toEqual({ message: 'Extension deleted successfully' }); + expect(mockPrisma.extension.delete.calls).toHaveLength(1); + }); + }); + + // ─── toggleActive ──────────────────────────────────────────────────── + + describe('toggleActive()', () => { + it('should throw NotFoundException when extension does not exist', async () => { + mockPrisma.extension.findUnique.mockResolvedValue(null); + await expect(service.toggleActive(999)).rejects.toThrow(NotFoundException); + }); + + it('should toggle isActive from true to false', async () => { + mockPrisma.extension.findUnique.mockResolvedValue({ id: 1, name: 'Ext', isActive: true }); + const toggled = { id: 1, name: 'Ext', isActive: false }; + mockPrisma.extension.update.mockResolvedValue(toggled); + + const result = await service.toggleActive(1); + expect(result.isActive).toBe(false); + const updateCall = mockPrisma.extension.update.calls[0][0]; + expect(updateCall.data.isActive).toBe(false); + }); + + it('should toggle isActive from false to true', async () => { + mockPrisma.extension.findUnique.mockResolvedValue({ id: 2, name: 'Ext2', isActive: false }); + const toggled = { id: 2, name: 'Ext2', isActive: true }; + mockPrisma.extension.update.mockResolvedValue(toggled); + + const result = await service.toggleActive(2); + expect(result.isActive).toBe(true); + const updateCall = mockPrisma.extension.update.calls[0][0]; + expect(updateCall.data.isActive).toBe(true); + }); + }); +}); diff --git a/backend/src/extensions/extensions.service.ts b/backend/src/extensions/extensions.service.ts new file mode 100644 index 0000000..d25d400 --- /dev/null +++ b/backend/src/extensions/extensions.service.ts @@ -0,0 +1,137 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateExtensionDto } from './dto/create-extension.dto'; +import { UpdateExtensionDto } from './dto/update-extension.dto'; +import { wrapListResponse } from '../common/utils/response.util'; + +@Injectable() +export class ExtensionsService { + private readonly logger = new Logger(ExtensionsService.name); + + constructor(private prisma: PrismaService) {} + + /** + * Create a new extension + */ + async create(createExtensionDto: CreateExtensionDto) { + const extension = await this.prisma.extension.create({ + data: { + name: createExtensionDto.name, + description: createExtensionDto.description, + type: createExtensionDto.type, + config: createExtensionDto.config || {}, + isActive: createExtensionDto.isActive ?? true, + }, + }); + + this.logger.log(`Extension created: ${extension.name}`); + + return extension; + } + + /** + * Find all extensions + */ + async findAll(activeOnly = false) { + const where = activeOnly ? { isActive: true } : {}; + + const extensions = await this.prisma.extension.findMany({ + where, + orderBy: { + createdAt: 'desc', + }, + }); + + return wrapListResponse(extensions); + } + + /** + * Find one extension by ID + */ + async findOne(id: number) { + const extension = await this.prisma.extension.findUnique({ + where: { id }, + }); + + if (!extension) { + throw new NotFoundException('Extension not found'); + } + + return extension; + } + + /** + * Update extension + */ + async update(id: number, updateExtensionDto: UpdateExtensionDto) { + const extension = await this.prisma.extension.findUnique({ + where: { id }, + }); + + if (!extension) { + throw new NotFoundException('Extension not found'); + } + + const updatedExtension = await this.prisma.extension.update({ + where: { id }, + data: { + name: updateExtensionDto.name, + description: updateExtensionDto.description, + type: updateExtensionDto.type, + config: updateExtensionDto.config, + isActive: updateExtensionDto.isActive, + }, + }); + + this.logger.log(`Extension updated: ${updatedExtension.name}`); + + return updatedExtension; + } + + /** + * Delete extension + */ + async remove(id: number) { + const extension = await this.prisma.extension.findUnique({ + where: { id }, + }); + + if (!extension) { + throw new NotFoundException('Extension not found'); + } + + await this.prisma.extension.delete({ + where: { id }, + }); + + this.logger.log(`Extension deleted: ${extension.name}`); + + return { message: 'Extension deleted successfully' }; + } + + /** + * Toggle extension active status + */ + async toggleActive(id: number) { + const extension = await this.prisma.extension.findUnique({ + where: { id }, + }); + + if (!extension) { + throw new NotFoundException('Extension not found'); + } + + const updatedExtension = await this.prisma.extension.update({ + where: { id }, + data: { + isActive: !extension.isActive, + }, + }); + + this.logger.log( + `Extension ${updatedExtension.name} ${updatedExtension.isActive ? 'activated' : 'deactivated'}`, + ); + + return updatedExtension; + } +} diff --git a/backend/src/firmware/dto/create-firmware.dto.ts b/backend/src/firmware/dto/create-firmware.dto.ts new file mode 100644 index 0000000..87f94db --- /dev/null +++ b/backend/src/firmware/dto/create-firmware.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsUrl, MaxLength } from 'class-validator'; + +export class CreateFirmwareDto { + @ApiProperty({ + example: '1.2.3', + description: 'Firmware version number', + }) + @IsString() + @MaxLength(50) + version: string; + + @ApiProperty({ + example: 'https://example.com/firmware/v1.2.3.bin', + description: 'Firmware download URL', + }) + @IsString() + @IsUrl() + @MaxLength(2048) + downloadUrl: string; + + @ApiPropertyOptional({ + example: 'Bug fixes and performance improvements', + description: 'Release notes', + }) + @IsOptional() + @IsString() + @MaxLength(5000) + releaseNotes?: string; + + @ApiPropertyOptional({ + example: true, + description: 'Whether this is a stable release', + default: false, + }) + @IsOptional() + @IsBoolean() + isStable?: boolean; +} diff --git a/backend/src/firmware/dto/update-firmware.dto.ts b/backend/src/firmware/dto/update-firmware.dto.ts new file mode 100644 index 0000000..cac39e0 --- /dev/null +++ b/backend/src/firmware/dto/update-firmware.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsUrl, MaxLength } from 'class-validator'; + +export class UpdateFirmwareDto { + @ApiPropertyOptional({ + example: '1.2.3', + description: 'Firmware version number', + }) + @IsOptional() + @IsString() + @MaxLength(50) + version?: string; + + @ApiPropertyOptional({ + example: 'https://example.com/firmware/v1.2.3.bin', + description: 'Firmware download URL', + }) + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(2048) + downloadUrl?: string; + + @ApiPropertyOptional({ + example: 'Bug fixes and performance improvements', + description: 'Release notes', + }) + @IsOptional() + @IsString() + @MaxLength(5000) + releaseNotes?: string; + + @ApiPropertyOptional({ + example: true, + description: 'Whether this is a stable release', + }) + @IsOptional() + @IsBoolean() + isStable?: boolean; +} diff --git a/backend/src/firmware/firmware.controller.test.ts b/backend/src/firmware/firmware.controller.test.ts new file mode 100644 index 0000000..7ebbff4 --- /dev/null +++ b/backend/src/firmware/firmware.controller.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { FirmwareController } from './firmware.controller'; +import { FirmwareService } from './firmware.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('FirmwareController (e2e)', () => { + let app: INestApplication; + + const mockFirmware = { + id: 1, + version: '1.0.0', + downloadUrl: 'https://example.com/firmware/1.0.0.bin', + releaseNotes: 'Initial release', + isStable: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockFirmwareService = { + create: async () => mockFirmware, + findAll: async () => ({ items: [mockFirmware], total: 1 }), + findOne: async () => mockFirmware, + findLatestStable: async () => mockFirmware, + findByVersion: async () => mockFirmware, + update: async () => ({ ...mockFirmware, releaseNotes: 'Updated notes' }), + remove: async () => ({ message: 'Firmware deleted successfully' }), + markAsStable: async () => ({ ...mockFirmware, isStable: true }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [FirmwareController], + providers: [ + { provide: FirmwareService, useValue: mockFirmwareService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /firmware', () => { + it('should return list of firmware versions', async () => { + const response = await request(app.getHttpServer()) + .get('/firmware') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + }); + }); + + describe('POST /firmware', () => { + it('should create a new firmware version', async () => { + const response = await request(app.getHttpServer()) + .post('/firmware') + .send({ version: '1.0.0', downloadUrl: 'https://example.com/fw.bin' }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('version', '1.0.0'); + }); + }); + + describe('PATCH /firmware/:id', () => { + it('should update a firmware version', async () => { + const response = await request(app.getHttpServer()) + .patch('/firmware/1') + .send({ releaseNotes: 'Updated notes' }) + .expect(200); + + expect(response.body.data).toHaveProperty('releaseNotes', 'Updated notes'); + }); + }); +}); diff --git a/backend/src/firmware/firmware.controller.ts b/backend/src/firmware/firmware.controller.ts new file mode 100644 index 0000000..53c3282 --- /dev/null +++ b/backend/src/firmware/firmware.controller.ts @@ -0,0 +1,109 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { FirmwareService } from './firmware.service'; +import { CreateFirmwareDto } from './dto/create-firmware.dto'; +import { UpdateFirmwareDto } from './dto/update-firmware.dto'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('firmware') +@Controller('firmware') +export class FirmwareController { + constructor(private readonly firmwareService: FirmwareService) {} + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Create a new firmware version' }) + @ApiResponse({ status: 201, description: 'Firmware successfully created' }) + @ApiResponse({ status: 409, description: 'Firmware version already exists' }) + create(@Body() createFirmwareDto: CreateFirmwareDto) { + return this.firmwareService.create(createFirmwareDto); + } + + @Get() + @Public() + @ApiOperation({ summary: 'Get all firmware versions' }) + @ApiResponse({ status: 200, description: 'List of firmware versions' }) + @ApiQuery({ + name: 'stableOnly', + required: false, + type: Boolean, + description: 'Filter to stable versions only', + }) + findAll(@Query('stableOnly') stableOnly?: string) { + return this.firmwareService.findAll(stableOnly === 'true'); + } + + @Public() + @Get('latest/stable') + @ApiOperation({ summary: 'Get latest stable firmware version' }) + @ApiResponse({ status: 200, description: 'Latest stable firmware version' }) + @ApiResponse({ status: 404, description: 'No stable firmware available' }) + findLatestStable() { + return this.firmwareService.findLatestStable(); + } + + @Get(':id') + @Public() + @ApiOperation({ summary: 'Get firmware by ID' }) + @ApiResponse({ status: 200, description: 'Firmware details' }) + @ApiResponse({ status: 404, description: 'Firmware not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.firmwareService.findOne(id); + } + + @Public() + @Get('version/:version') + @ApiOperation({ summary: 'Get firmware by version string' }) + @ApiResponse({ status: 200, description: 'Firmware details' }) + @ApiResponse({ status: 404, description: 'Firmware version not found' }) + findByVersion(@Param('version') version: string) { + return this.firmwareService.findByVersion(version); + } + + @Patch(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update firmware' }) + @ApiResponse({ status: 200, description: 'Firmware successfully updated' }) + @ApiResponse({ status: 404, description: 'Firmware not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateFirmwareDto: UpdateFirmwareDto, + ) { + return this.firmwareService.update(id, updateFirmwareDto); + } + + @Delete(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Delete firmware' }) + @ApiResponse({ status: 200, description: 'Firmware successfully deleted' }) + @ApiResponse({ status: 404, description: 'Firmware not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.firmwareService.remove(id); + } + + @Post(':id/mark-stable') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Mark firmware as stable' }) + @ApiResponse({ status: 200, description: 'Firmware marked as stable' }) + @ApiResponse({ status: 404, description: 'Firmware not found' }) + markAsStable(@Param('id', ParseIntPipe) id: number) { + return this.firmwareService.markAsStable(id); + } +} diff --git a/backend/src/firmware/firmware.module.ts b/backend/src/firmware/firmware.module.ts new file mode 100644 index 0000000..165263d --- /dev/null +++ b/backend/src/firmware/firmware.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { FirmwareController } from './firmware.controller'; +import { FirmwareService } from './firmware.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [FirmwareController], + providers: [FirmwareService], + exports: [FirmwareService], +}) +export class FirmwareModule {} diff --git a/backend/src/firmware/firmware.service.test.ts b/backend/src/firmware/firmware.service.test.ts new file mode 100644 index 0000000..0fbcf2b --- /dev/null +++ b/backend/src/firmware/firmware.service.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { FirmwareService } from './firmware.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; + +describe('FirmwareService', () => { + let service: FirmwareService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new FirmwareService(mockPrisma as any); + }); + + // ─── create ────────────────────────────────────────────────────────── + + describe('create()', () => { + it('should throw ConflictException when version already exists', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue({ id: 1, version: '1.0.0' }); + await expect( + service.create({ version: '1.0.0', downloadUrl: 'https://example.com/fw' } as any), + ).rejects.toThrow(ConflictException); + }); + + it('should create firmware when version is new', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue(null); + const created = { id: 1, version: '2.0.0', isStable: false }; + mockPrisma.firmware.create.mockResolvedValue(created); + + const result = await service.create({ + version: '2.0.0', + downloadUrl: 'https://example.com/fw', + } as any); + expect(result).toEqual(created); + expect(mockPrisma.firmware.create.calls).toHaveLength(1); + }); + }); + + // ─── findLatestStable ──────────────────────────────────────────────── + + describe('findLatestStable()', () => { + it('should throw NotFoundException when no stable firmware exists', async () => { + mockPrisma.firmware.findFirst.mockResolvedValue(null); + await expect(service.findLatestStable()).rejects.toThrow(NotFoundException); + }); + + it('should return the latest stable firmware', async () => { + const fw = { id: 3, version: '1.5.0', isStable: true }; + mockPrisma.firmware.findFirst.mockResolvedValue(fw); + + const result = await service.findLatestStable(); + expect(result).toEqual(fw); + }); + }); + + // ─── findOne ───────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should throw NotFoundException when not found', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + }); + + // ─── update ────────────────────────────────────────────────────────── + + describe('update()', () => { + it('should throw NotFoundException when firmware does not exist', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue(null); + await expect(service.update(999, { version: '3.0.0' } as any)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw ConflictException when changing to an existing version', async () => { + // First call: find the firmware being updated + // Second call: find firmware with the target version + let callCount = 0; + mockPrisma.firmware.findUnique.mockImplementation((...args: any[]) => { + callCount++; + if (callCount === 1) return Promise.resolve({ id: 1, version: '1.0.0' }); + return Promise.resolve({ id: 2, version: '2.0.0' }); // conflict + }); + + await expect(service.update(1, { version: '2.0.0' } as any)).rejects.toThrow( + ConflictException, + ); + }); + }); + + // ─── remove ────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should throw NotFoundException when firmware does not exist', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should delete firmware successfully', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue({ id: 1, version: '1.0.0' }); + mockPrisma.firmware.delete.mockResolvedValue({}); + + const result = await service.remove(1); + expect(result).toEqual({ message: 'Firmware deleted successfully' }); + expect(mockPrisma.firmware.delete.calls).toHaveLength(1); + }); + }); + + // ─── markAsStable ──────────────────────────────────────────────────── + + describe('markAsStable()', () => { + it('should throw NotFoundException when firmware does not exist', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue(null); + await expect(service.markAsStable(999)).rejects.toThrow(NotFoundException); + }); + + it('should update isStable to true', async () => { + mockPrisma.firmware.findUnique.mockResolvedValue({ id: 1, version: '1.0.0' }); + const updated = { id: 1, version: '1.0.0', isStable: true }; + mockPrisma.firmware.update.mockResolvedValue(updated); + + const result = await service.markAsStable(1); + expect(result).toEqual(updated); + expect(mockPrisma.firmware.update.calls).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/firmware/firmware.service.ts b/backend/src/firmware/firmware.service.ts new file mode 100644 index 0000000..e6cc41c --- /dev/null +++ b/backend/src/firmware/firmware.service.ts @@ -0,0 +1,189 @@ +import { + Injectable, + NotFoundException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateFirmwareDto } from './dto/create-firmware.dto'; +import { UpdateFirmwareDto } from './dto/update-firmware.dto'; +import { wrapListResponse } from '../common/utils/response.util'; + +@Injectable() +export class FirmwareService { + private readonly logger = new Logger(FirmwareService.name); + + constructor(private prisma: PrismaService) {} + + /** + * Create a new firmware version + */ + async create(createFirmwareDto: CreateFirmwareDto) { + // Check if version already exists + const existingFirmware = await this.prisma.firmware.findUnique({ + where: { version: createFirmwareDto.version }, + }); + + if (existingFirmware) { + throw new ConflictException('Firmware version already exists'); + } + + const firmware = await this.prisma.firmware.create({ + data: { + version: createFirmwareDto.version, + downloadUrl: createFirmwareDto.downloadUrl, + releaseNotes: createFirmwareDto.releaseNotes, + isStable: createFirmwareDto.isStable ?? false, + }, + }); + + this.logger.log(`Firmware version ${firmware.version} created`); + + return firmware; + } + + /** + * Find all firmware versions + */ + async findAll(stableOnly = false) { + const where = stableOnly ? { isStable: true } : {}; + + const firmware = await this.prisma.firmware.findMany({ + where, + orderBy: { + createdAt: 'desc', + }, + }); + + return wrapListResponse(firmware); + } + + /** + * Find latest stable firmware + */ + async findLatestStable() { + const firmware = await this.prisma.firmware.findFirst({ + where: { isStable: true }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (!firmware) { + throw new NotFoundException('No stable firmware version available'); + } + + return firmware; + } + + /** + * Find one firmware by ID + */ + async findOne(id: number) { + const firmware = await this.prisma.firmware.findUnique({ + where: { id }, + }); + + if (!firmware) { + throw new NotFoundException('Firmware not found'); + } + + return firmware; + } + + /** + * Find firmware by version + */ + async findByVersion(version: string) { + const firmware = await this.prisma.firmware.findUnique({ + where: { version }, + }); + + if (!firmware) { + throw new NotFoundException('Firmware version not found'); + } + + return firmware; + } + + /** + * Update firmware + */ + async update(id: number, updateFirmwareDto: UpdateFirmwareDto) { + const firmware = await this.prisma.firmware.findUnique({ + where: { id }, + }); + + if (!firmware) { + throw new NotFoundException('Firmware not found'); + } + + // Check if version is being changed to an existing version + if (updateFirmwareDto.version && updateFirmwareDto.version !== firmware.version) { + const existingFirmware = await this.prisma.firmware.findUnique({ + where: { version: updateFirmwareDto.version }, + }); + + if (existingFirmware) { + throw new ConflictException('Firmware version already exists'); + } + } + + const updatedFirmware = await this.prisma.firmware.update({ + where: { id }, + data: { + version: updateFirmwareDto.version, + downloadUrl: updateFirmwareDto.downloadUrl, + releaseNotes: updateFirmwareDto.releaseNotes, + isStable: updateFirmwareDto.isStable, + }, + }); + + this.logger.log(`Firmware version ${updatedFirmware.version} updated`); + + return updatedFirmware; + } + + /** + * Delete firmware + */ + async remove(id: number) { + const firmware = await this.prisma.firmware.findUnique({ + where: { id }, + }); + + if (!firmware) { + throw new NotFoundException('Firmware not found'); + } + + await this.prisma.firmware.delete({ + where: { id }, + }); + + this.logger.log(`Firmware version ${firmware.version} deleted`); + + return { message: 'Firmware deleted successfully' }; + } + + /** + * Mark firmware as stable + */ + async markAsStable(id: number) { + const firmware = await this.prisma.firmware.findUnique({ + where: { id }, + }); + + if (!firmware) { + throw new NotFoundException('Firmware not found'); + } + + const updatedFirmware = await this.prisma.firmware.update({ + where: { id }, + data: { isStable: true }, + }); + + this.logger.log(`Firmware version ${updatedFirmware.version} marked as stable`); + + return updatedFirmware; + } +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts new file mode 100644 index 0000000..a46e395 --- /dev/null +++ b/backend/src/health/health.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { + HealthCheckService, + HealthCheck, + PrismaHealthIndicator, +} from '@nestjs/terminus'; +import { PrismaService } from '../prisma/prisma.service'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('health') +@Controller() +@Public() +@SkipThrottle() +export class HealthController { + constructor( + private health: HealthCheckService, + private prismaHealth: PrismaHealthIndicator, + private prisma: PrismaService, + ) {} + + @Get('health') + @HealthCheck() + @ApiOperation({ summary: 'Get application health status' }) + check() { + return this.health.check([ + () => this.prismaHealth.pingCheck('database', this.prisma), + ]); + } + + @Get('ready') + @ApiOperation({ summary: 'Check if application is ready to serve traffic' }) + ready() { + return { + status: 'ready', + timestamp: new Date().toISOString(), + }; + } +} \ No newline at end of file diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts new file mode 100644 index 0000000..3c5370e --- /dev/null +++ b/backend/src/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} \ No newline at end of file diff --git a/backend/src/jobs/jobs.module.ts b/backend/src/jobs/jobs.module.ts new file mode 100644 index 0000000..75cad58 --- /dev/null +++ b/backend/src/jobs/jobs.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ModelPollerService } from './services/model-poller.service'; +import { ModelPollerProcessor } from './processors/model-poller.processor'; +import { LogCleanupService } from './services/log-cleanup.service'; +import { LogCleanupProcessor } from './processors/log-cleanup.processor'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ModelsModule } from '../models/models.module'; + +/** + * Jobs Module + * Manages background jobs using BullMQ queues with Redis + */ +@Module({ + imports: [ + BullModule.registerQueue( + { name: 'model-poller' }, + { name: 'log-cleanup' }, + ), + PrismaModule, + ModelsModule, + ], + providers: [ + ModelPollerService, + ModelPollerProcessor, + LogCleanupService, + LogCleanupProcessor, + ], + exports: [ + ModelPollerService, + LogCleanupService, + ], +}) +export class JobsModule {} diff --git a/backend/src/jobs/processors/log-cleanup.processor.ts b/backend/src/jobs/processors/log-cleanup.processor.ts new file mode 100644 index 0000000..d0d81a8 --- /dev/null +++ b/backend/src/jobs/processors/log-cleanup.processor.ts @@ -0,0 +1,29 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Processor('log-cleanup') +export class LogCleanupProcessor extends WorkerHost { + private readonly logger = new Logger(LogCleanupProcessor.name); + + constructor(private prisma: PrismaService) { + super(); + } + + async process(job: Job) { + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + + this.logger.log(`Cleaning up device logs older than ${cutoff.toISOString()}`); + + const result = await this.prisma.deviceLog.deleteMany({ + where: { + createdAt: { lt: cutoff }, + }, + }); + + this.logger.log(`Deleted ${result.count} old device log entries`); + + return { deleted: result.count }; + } +} diff --git a/backend/src/jobs/processors/model-poller.processor.ts b/backend/src/jobs/processors/model-poller.processor.ts new file mode 100644 index 0000000..80e5891 --- /dev/null +++ b/backend/src/jobs/processors/model-poller.processor.ts @@ -0,0 +1,119 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; + +/** + * Model Poller Processor + * Polls Core Models API for new or updated device models + * Syncs device model definitions (screen dimensions, capabilities, etc.) + */ +@Processor('model-poller') +export class ModelPollerProcessor extends WorkerHost { + private readonly logger = new Logger(ModelPollerProcessor.name); + + constructor( + private prisma: PrismaService, + private configService: ConfigService, + ) { + super(); + } + + async process(job: Job) { + this.logger.log('Processing model poll job...'); + + try { + const modelsApiUrl = this.configService.get('models.apiUrl'); + + if (!modelsApiUrl) { + this.logger.debug( + 'Models API URL not configured, skipping external poll', + ); + return await this.checkLocalModels(); + } + + // Fetch models from external API + const response = await axios.get(modelsApiUrl, { + timeout: 10000, + }); + + const externalModels = response.data; + + if (!Array.isArray(externalModels)) { + throw new Error('Invalid response from models API'); + } + + // Sync models to database + let created = 0; + let updated = 0; + + for (const modelData of externalModels) { + const existingModel = await this.prisma.model.findUnique({ + where: { name: modelData.name }, + }); + + if (existingModel) { + // Update existing model if data changed + await this.prisma.model.update({ + where: { id: existingModel.id }, + data: { + label: modelData.label, + width: modelData.width, + height: modelData.height, + description: modelData.description, + colors: modelData.colors || 2, + bitDepth: modelData.bitDepth || 1, + }, + }); + updated++; + } else { + // Create new model + await this.prisma.model.create({ + data: { + name: modelData.name, + label: modelData.label, + width: modelData.width, + height: modelData.height, + description: modelData.description, + colors: modelData.colors || 2, + bitDepth: modelData.bitDepth || 1, + }, + }); + created++; + } + } + + this.logger.log( + `Model sync complete. Created: ${created}, Updated: ${updated}`, + ); + + return { + success: true, + created, + updated, + total: externalModels.length, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + this.logger.warn(`Models API unreachable: ${error.message}`); + return await this.checkLocalModels(); + } + + this.logger.error('Model poll failed:', error); + throw error; + } + } + + private async checkLocalModels() { + const modelCount = await this.prisma.model.count(); + this.logger.log(`Local model check complete. Total models: ${modelCount}`); + + return { + success: true, + modelCount, + source: 'local', + }; + } +} diff --git a/backend/src/jobs/services/log-cleanup.service.ts b/backend/src/jobs/services/log-cleanup.service.ts new file mode 100644 index 0000000..aef4a07 --- /dev/null +++ b/backend/src/jobs/services/log-cleanup.service.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +@Injectable() +export class LogCleanupService implements OnModuleInit { + private readonly logger = new Logger(LogCleanupService.name); + + constructor( + @InjectQueue('log-cleanup') private logCleanupQueue: Queue, + ) {} + + async onModuleInit() { + await this.startCleanupJob(); + } + + private async startCleanupJob() { + const interval = 24 * 60 * 60 * 1000; // 24 hours + + this.logger.log('Starting device log cleanup job (daily)'); + + await this.logCleanupQueue.add( + 'cleanup-logs', + {}, + { + repeat: { every: interval }, + removeOnComplete: true, + removeOnFail: { count: 5 }, + }, + ); + + // Run once on startup after a short delay + await this.logCleanupQueue.add('cleanup-logs', {}, { + delay: 30000, + removeOnComplete: true, + removeOnFail: true, + }); + + this.logger.log('Device log cleanup job scheduled'); + } +} diff --git a/backend/src/jobs/services/model-poller.service.ts b/backend/src/jobs/services/model-poller.service.ts new file mode 100644 index 0000000..8323c51 --- /dev/null +++ b/backend/src/jobs/services/model-poller.service.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { ConfigService } from '@nestjs/config'; + +/** + * Model Poller Service + * Polls the Core Models API for new or updated device models + * Similar to Ruby's Models::Poller aspect + */ +@Injectable() +export class ModelPollerService implements OnModuleInit { + private readonly logger = new Logger(ModelPollerService.name); + + constructor( + @InjectQueue('model-poller') private modelQueue: Queue, + private configService: ConfigService, + ) {} + + async onModuleInit() { + const enabled = this.configService.get( + 'jobs.modelPoller.enabled', + true, + ); + + if (!enabled) { + this.logger.log('Model poller is disabled'); + return; + } + + await this.startPoller(); + } + + private async startPoller() { + // Poll every 24 hours by default + const interval = this.configService.get( + 'jobs.modelPoller.interval', + 86400000, // 24 hours in milliseconds + ); + + this.logger.log(`Starting model poller with interval: ${interval / 1000}s`); + + // Add repeatable job + await this.modelQueue.add( + 'poll-models', + {}, + { + repeat: { + every: interval, + }, + removeOnComplete: true, + removeOnFail: { count: 10 }, + }, + ); + + // Run immediately on startup + await this.modelQueue.add('poll-models', {}, { delay: 5000, removeOnComplete: true, removeOnFail: true }); + + this.logger.log('Model poller started'); + } + + async stopPoller() { + // Clean completed and failed jobs (grace period 0ms) + await this.modelQueue.clean(0, 0, 'completed'); + await this.modelQueue.clean(0, 0, 'failed'); + await this.modelQueue.drain(); + + const repeatableJobs = await this.modelQueue.getRepeatableJobs(); + for (const job of repeatableJobs) { + await this.modelQueue.removeRepeatableByKey(job.key); + } + + this.logger.log('Model poller stopped'); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..6fef27a --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,115 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import helmet from 'helmet'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const compression = require('compression'); +import { WinstonModule } from 'nest-winston'; +import { AppModule } from './app.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { createLoggerConfig } from './config/logger.config'; + +async function bootstrap() { + // Create logger instance + const logger = WinstonModule.createLogger(createLoggerConfig()); + + const app = await NestFactory.create(AppModule, { + logger, + }); + + // Get config service + const configService = app.get(ConfigService); + const port = configService.get('port', 3000); + const environment = configService.get('environment', 'development'); + + // Security middleware - allow cross-origin resource loading for images + app.use(helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: environment === 'production', + })); + app.use(compression()); + + // Enable CORS - restrict origins via CORS_ORIGINS env (comma-separated), defaults to all + const corsOrigins = process.env.CORS_ORIGINS || '*'; + if (corsOrigins === '*') { + app.enableCors(); + } else { + app.enableCors({ + origin: corsOrigins.split(',').map((o) => o.trim()), + credentials: true, + }); + } + + // Global prefix - exclude /api routes since they're for device communication + app.setGlobalPrefix('api', { + exclude: ['health', 'ready', 'api/display', 'api/setup', 'api/setup/', 'api/log', 'api/device-images/design/:id', 'api/device-images/device/:id'], + }); + + // Global pipes + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Global filters + app.useGlobalFilters(new HttpExceptionFilter()); + + // Global interceptors + app.useGlobalInterceptors(new TransformInterceptor()); + + // Swagger documentation setup + if (environment !== 'production') { + const config = new DocumentBuilder() + .setTitle('Inker API') + .setDescription('API documentation for Inker e-ink device management server') + .setVersion('1.0.0-Alpha') + .addBearerAuth() + .addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } + + // Graceful shutdown handling + const shutdownSignals = ['SIGTERM', 'SIGINT']; + shutdownSignals.forEach((signal) => { + process.on(signal, async () => { + logger.log(`Received ${signal}, starting graceful shutdown...`); + try { + await app.close(); + logger.log('Application closed successfully'); + process.exit(0); + } catch (error) { + logger.error('Error during graceful shutdown:', error); + process.exit(1); + } + }); + }); + + await app.listen(port, '0.0.0.0'); + logger.log(`Inker Server running in ${environment} mode on port ${port} (listening on all interfaces)`); + + // Warn if using default PIN + const adminPin = process.env.ADMIN_PIN || '1111'; + if (adminPin === '1111') { + logger.warn('WARNING: Using default ADMIN_PIN "1111". Change it via the ADMIN_PIN environment variable for security.'); + } + + if (environment !== 'production') { + logger.log(`📚 API Documentation available at http://localhost:${port}/api/docs`); + } +} + +bootstrap().catch((error) => { + console.error('Failed to start application:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/src/models/dto/create-model.dto.ts b/backend/src/models/dto/create-model.dto.ts new file mode 100644 index 0000000..7a6cfad --- /dev/null +++ b/backend/src/models/dto/create-model.dto.ts @@ -0,0 +1,92 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsInt, + IsOptional, + IsNumber, + Min, + IsIn, + MaxLength, +} from 'class-validator'; + +export class CreateModelDto { + @ApiProperty({ example: 'og_png', description: 'Unique model identifier name' }) + @IsString() + @MaxLength(255) + name: string; + + @ApiProperty({ example: 'TRMNL Original', description: 'Display label for model' }) + @IsString() + @MaxLength(255) + label: string; + + @ApiProperty({ example: 800, description: 'Screen width in pixels' }) + @IsInt() + @Min(1) + width: number; + + @ApiProperty({ example: 480, description: 'Screen height in pixels' }) + @IsInt() + @Min(1) + height: number; + + @ApiProperty({ + example: 'Original TRMNL device with e-ink display', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiProperty({ example: 'image/png', default: 'image/png' }) + @IsOptional() + @IsString() + @MaxLength(100) + mimeType?: string; + + @ApiProperty({ example: 2, default: 2, description: 'Number of colors' }) + @IsOptional() + @IsInt() + colors?: number; + + @ApiProperty({ example: 1, default: 1, description: 'Bit depth' }) + @IsOptional() + @IsInt() + bitDepth?: number; + + @ApiProperty({ example: 0, default: 0, description: 'Screen rotation degrees' }) + @IsOptional() + @IsInt() + rotation?: number; + + @ApiProperty({ example: 0, default: 0, description: 'X offset in pixels' }) + @IsOptional() + @IsInt() + offsetX?: number; + + @ApiProperty({ example: 0, default: 0, description: 'Y offset in pixels' }) + @IsOptional() + @IsInt() + offsetY?: number; + + @ApiProperty({ + example: 'terminus', + default: 'terminus', + description: 'Model kind (terminus, custom, etc)', + }) + @IsOptional() + @IsString() + @MaxLength(50) + kind?: string; + + @ApiProperty({ + example: 1.0, + default: 1.0, + description: 'Scaling factor for rendering', + }) + @IsOptional() + @IsNumber() + @Min(0.1) + scaleFactor?: number; +} diff --git a/backend/src/models/dto/update-model.dto.ts b/backend/src/models/dto/update-model.dto.ts new file mode 100644 index 0000000..b2f7dab --- /dev/null +++ b/backend/src/models/dto/update-model.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateModelDto } from './create-model.dto'; + +export class UpdateModelDto extends PartialType(CreateModelDto) {} diff --git a/backend/src/models/models.controller.test.ts b/backend/src/models/models.controller.test.ts new file mode 100644 index 0000000..86aac34 --- /dev/null +++ b/backend/src/models/models.controller.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { ModelsController } from './models.controller'; +import { ModelsService } from './models.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('ModelsController (e2e)', () => { + let app: INestApplication; + + const mockModel = { + id: 1, + name: 'trmnl-7.5', + label: 'TRMNL 7.5"', + width: 800, + height: 480, + description: 'Standard e-ink display', + mimeType: 'image/png', + colors: 2, + bitDepth: 1, + rotation: 0, + kind: 'terminus', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockModelsService = { + create: async () => mockModel, + findAll: async () => ({ items: [mockModel], total: 1 }), + findOne: async () => mockModel, + update: async () => ({ ...mockModel, label: 'Updated Label' }), + remove: async () => ({ message: 'Model deleted successfully' }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [ModelsController], + providers: [ + { provide: ModelsService, useValue: mockModelsService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /models', () => { + it('should return list of models', async () => { + const response = await request(app.getHttpServer()) + .get('/models') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + }); + }); + + describe('POST /models', () => { + it('should create a new model', async () => { + const response = await request(app.getHttpServer()) + .post('/models') + .send({ name: 'trmnl-7.5', label: 'TRMNL 7.5"', width: 800, height: 480 }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('name', 'trmnl-7.5'); + }); + }); + + describe('DELETE /models/:id', () => { + it('should delete a model', async () => { + const response = await request(app.getHttpServer()) + .delete('/models/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Model deleted successfully'); + }); + }); +}); diff --git a/backend/src/models/models.controller.ts b/backend/src/models/models.controller.ts new file mode 100644 index 0000000..acdd7f0 --- /dev/null +++ b/backend/src/models/models.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { ModelsService } from './models.service'; +import { CreateModelDto } from './dto/create-model.dto'; +import { UpdateModelDto } from './dto/update-model.dto'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('models') +@Controller('models') +export class ModelsController { + constructor(private readonly modelsService: ModelsService) {} + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Create a new device model' }) + @ApiResponse({ status: 201, description: 'Model successfully created' }) + @ApiResponse({ status: 400, description: 'Model with name already exists' }) + create(@Body() createModelDto: CreateModelDto) { + return this.modelsService.create(createModelDto); + } + + @Get() + @Public() + @ApiOperation({ summary: 'Get all device models' }) + @ApiResponse({ status: 200, description: 'List of models' }) + findAll() { + return this.modelsService.findAll(); + } + + @Get(':id') + @Public() + @ApiOperation({ summary: 'Get model by ID' }) + @ApiResponse({ status: 200, description: 'Model details' }) + @ApiResponse({ status: 404, description: 'Model not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.modelsService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update model' }) + @ApiResponse({ status: 200, description: 'Model successfully updated' }) + @ApiResponse({ status: 404, description: 'Model not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateModelDto: UpdateModelDto, + ) { + return this.modelsService.update(id, updateModelDto); + } + + @Delete(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Delete model' }) + @ApiResponse({ status: 200, description: 'Model successfully deleted' }) + @ApiResponse({ status: 404, description: 'Model not found' }) + @ApiResponse({ + status: 400, + description: 'Cannot delete: devices are using this model', + }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.modelsService.remove(id); + } +} diff --git a/backend/src/models/models.module.ts b/backend/src/models/models.module.ts new file mode 100644 index 0000000..93dfc54 --- /dev/null +++ b/backend/src/models/models.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ModelsService } from './models.service'; +import { ModelsController } from './models.controller'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ModelsController], + providers: [ModelsService], + exports: [ModelsService], +}) +export class ModelsModule {} diff --git a/backend/src/models/models.service.test.ts b/backend/src/models/models.service.test.ts new file mode 100644 index 0000000..5e98333 --- /dev/null +++ b/backend/src/models/models.service.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { ModelsService } from './models.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; + +describe('ModelsService', () => { + let service: ModelsService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new ModelsService(mockPrisma as any); + }); + + // ─── create ────────────────────────────────────────────────────────── + + describe('create()', () => { + it('should throw BadRequestException when name already exists', async () => { + mockPrisma.model.findUnique.mockResolvedValue({ id: 1, name: 'TRMNL' }); + await expect( + service.create({ name: 'TRMNL', width: 800, height: 480 } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should create model when name is unique', async () => { + mockPrisma.model.findUnique.mockResolvedValue(null); + const created = { id: 1, name: 'NewModel', width: 800, height: 480 }; + mockPrisma.model.create.mockResolvedValue(created); + + const result = await service.create({ + name: 'NewModel', + width: 800, + height: 480, + } as any); + expect(result).toEqual(created); + expect(mockPrisma.model.create.calls).toHaveLength(1); + }); + }); + + // ─── findOne ───────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should throw NotFoundException when not found', async () => { + mockPrisma.model.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + + it('should return model when found', async () => { + const model = { id: 1, name: 'TRMNL', devices: [], screens: [], _count: { devices: 0, screens: 0 } }; + mockPrisma.model.findUnique.mockResolvedValue(model); + + const result = await service.findOne(1); + expect(result).toEqual(model); + }); + }); + + // ─── update ────────────────────────────────────────────────────────── + + describe('update()', () => { + it('should throw NotFoundException when model does not exist', async () => { + mockPrisma.model.findUnique.mockResolvedValue(null); + await expect(service.update(999, { name: 'X' } as any)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when changing to an existing name', async () => { + let callCount = 0; + mockPrisma.model.findUnique.mockImplementation((...args: any[]) => { + callCount++; + if (callCount === 1) return Promise.resolve({ id: 1, name: 'OldName' }); + return Promise.resolve({ id: 2, name: 'TakenName' }); // conflict + }); + + await expect(service.update(1, { name: 'TakenName' } as any)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ─── remove ────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should throw NotFoundException when model does not exist', async () => { + mockPrisma.model.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when devices are using the model', async () => { + mockPrisma.model.findUnique.mockResolvedValue({ + id: 1, + name: 'InUse', + _count: { devices: 3 }, + }); + await expect(service.remove(1)).rejects.toThrow(BadRequestException); + }); + + it('should delete model when no devices reference it', async () => { + mockPrisma.model.findUnique.mockResolvedValue({ + id: 1, + name: 'Unused', + _count: { devices: 0 }, + }); + mockPrisma.model.delete.mockResolvedValue({}); + + const result = await service.remove(1); + expect(result).toEqual({ message: 'Model deleted successfully' }); + expect(mockPrisma.model.delete.calls).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/models/models.service.ts b/backend/src/models/models.service.ts new file mode 100644 index 0000000..7479a47 --- /dev/null +++ b/backend/src/models/models.service.ts @@ -0,0 +1,199 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateModelDto } from './dto/create-model.dto'; +import { UpdateModelDto } from './dto/update-model.dto'; +import { wrapListResponse } from '../common/utils/response.util'; + +@Injectable() +export class ModelsService { + private readonly logger = new Logger(ModelsService.name); + + constructor(private prisma: PrismaService) {} + + /** + * Create a new device model + */ + async create(createModelDto: CreateModelDto) { + // Check if model with same name exists + const existing = await this.prisma.model.findUnique({ + where: { name: createModelDto.name }, + }); + + if (existing) { + throw new BadRequestException('Model with this name already exists'); + } + + const model = await this.prisma.model.create({ + data: { + name: createModelDto.name, + label: createModelDto.label, + width: createModelDto.width, + height: createModelDto.height, + description: createModelDto.description, + mimeType: createModelDto.mimeType || 'image/png', + colors: createModelDto.colors || 2, + bitDepth: createModelDto.bitDepth || 1, + rotation: createModelDto.rotation || 0, + offsetX: createModelDto.offsetX || 0, + offsetY: createModelDto.offsetY || 0, + kind: createModelDto.kind || 'terminus', + scaleFactor: createModelDto.scaleFactor || 1.0, + }, + }); + + this.logger.log(`Model created: ${model.name}`); + return model; + } + + /** + * Find all models + */ + async findAll() { + const models = await this.prisma.model.findMany({ + include: { + _count: { + select: { + devices: true, + screens: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return wrapListResponse(models); + } + + /** + * Find one model by ID + */ + async findOne(id: number) { + const model = await this.prisma.model.findUnique({ + where: { id }, + include: { + devices: { + select: { + id: true, + name: true, + macAddress: true, + isActive: true, + }, + }, + screens: { + select: { + id: true, + name: true, + imageUrl: true, + }, + take: 10, + }, + _count: { + select: { + devices: true, + screens: true, + }, + }, + }, + }); + + if (!model) { + throw new NotFoundException('Model not found'); + } + + return model; + } + + /** + * Find model by name + */ + async findByName(name: string) { + return this.prisma.model.findUnique({ + where: { name }, + }); + } + + /** + * Update model + */ + async update(id: number, updateModelDto: UpdateModelDto) { + const model = await this.prisma.model.findUnique({ + where: { id }, + }); + + if (!model) { + throw new NotFoundException('Model not found'); + } + + // Check name uniqueness if changing + if (updateModelDto.name && updateModelDto.name !== model.name) { + const existing = await this.prisma.model.findUnique({ + where: { name: updateModelDto.name }, + }); + + if (existing) { + throw new BadRequestException('Model with this name already exists'); + } + } + + const updated = await this.prisma.model.update({ + where: { id }, + data: { + name: updateModelDto.name, + label: updateModelDto.label, + width: updateModelDto.width, + height: updateModelDto.height, + description: updateModelDto.description, + mimeType: updateModelDto.mimeType, + colors: updateModelDto.colors, + bitDepth: updateModelDto.bitDepth, + rotation: updateModelDto.rotation, + offsetX: updateModelDto.offsetX, + offsetY: updateModelDto.offsetY, + kind: updateModelDto.kind, + scaleFactor: updateModelDto.scaleFactor, + }, + }); + + this.logger.log(`Model updated: ${updated.name}`); + return updated; + } + + /** + * Delete model + */ + async remove(id: number) { + const model = await this.prisma.model.findUnique({ + where: { id }, + include: { + _count: { + select: { devices: true }, + }, + }, + }); + + if (!model) { + throw new NotFoundException('Model not found'); + } + + // Prevent deletion if devices are using this model + if (model._count.devices > 0) { + throw new BadRequestException( + `Cannot delete model: ${model._count.devices} device(s) are using it`, + ); + } + + await this.prisma.model.delete({ + where: { id }, + }); + + this.logger.log(`Model deleted: ${model.name}`); + return { message: 'Model deleted successfully' }; + } +} diff --git a/backend/src/playlists/dto/add-playlist-item.dto.ts b/backend/src/playlists/dto/add-playlist-item.dto.ts new file mode 100644 index 0000000..b5ce4ca --- /dev/null +++ b/backend/src/playlists/dto/add-playlist-item.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class AddPlaylistItemDto { + @ApiProperty({ + example: 1, + description: 'Screen ID to add', + }) + @IsInt() + screenId: number; + + @ApiPropertyOptional({ + example: 0, + description: 'Display order (0-based index)', + }) + @IsOptional() + @IsInt() + @Min(0) + order?: number; + + @ApiPropertyOptional({ + example: 60, + description: 'Display duration in seconds', + default: 60, + }) + @IsOptional() + @IsInt() + @Min(1) + duration?: number; +} diff --git a/backend/src/playlists/dto/create-playlist.dto.ts b/backend/src/playlists/dto/create-playlist.dto.ts new file mode 100644 index 0000000..88ec207 --- /dev/null +++ b/backend/src/playlists/dto/create-playlist.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength } from 'class-validator'; +import { Type } from 'class-transformer'; + +class PlaylistScreenDto { + @IsString() + screenId: string; + + @IsOptional() + duration?: number; + + @IsOptional() + order?: number; +} + +export class CreatePlaylistDto { + @ApiProperty({ + example: 'Morning Rotation', + description: 'Playlist name', + }) + @IsString() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ + example: 'Screens to display in the morning', + description: 'Playlist description', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ + example: true, + description: 'Whether the playlist is active', + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + description: 'Screens in the playlist', + type: [PlaylistScreenDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PlaylistScreenDto) + screens?: PlaylistScreenDto[]; +} diff --git a/backend/src/playlists/dto/reorder-playlist-items.dto.ts b/backend/src/playlists/dto/reorder-playlist-items.dto.ts new file mode 100644 index 0000000..0debce7 --- /dev/null +++ b/backend/src/playlists/dto/reorder-playlist-items.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsInt } from 'class-validator'; + +class ItemOrder { + @ApiProperty({ example: 1, description: 'Item ID' }) + @IsInt() + id: number; + + @ApiProperty({ example: 0, description: 'New order position' }) + @IsInt() + order: number; +} + +export class ReorderPlaylistItemsDto { + @ApiProperty({ + type: [ItemOrder], + description: 'Array of item IDs with new orders', + example: [ + { id: 1, order: 0 }, + { id: 2, order: 1 }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ItemOrder) + items: ItemOrder[]; +} diff --git a/backend/src/playlists/dto/update-playlist-item.dto.ts b/backend/src/playlists/dto/update-playlist-item.dto.ts new file mode 100644 index 0000000..84d628f --- /dev/null +++ b/backend/src/playlists/dto/update-playlist-item.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class UpdatePlaylistItemDto { + @ApiPropertyOptional({ + example: 0, + description: 'Display order (0-based index)', + }) + @IsOptional() + @IsInt() + @Min(0) + order?: number; + + @ApiPropertyOptional({ + example: 60, + description: 'Display duration in seconds', + }) + @IsOptional() + @IsInt() + @Min(1) + duration?: number; +} diff --git a/backend/src/playlists/dto/update-playlist.dto.ts b/backend/src/playlists/dto/update-playlist.dto.ts new file mode 100644 index 0000000..917e068 --- /dev/null +++ b/backend/src/playlists/dto/update-playlist.dto.ts @@ -0,0 +1,52 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength } from 'class-validator'; +import { Type } from 'class-transformer'; + +class PlaylistScreenDto { + @IsString() + screenId: string; + + @IsOptional() + duration?: number; + + @IsOptional() + order?: number; +} + +export class UpdatePlaylistDto { + @ApiPropertyOptional({ + example: 'Morning Rotation', + description: 'Playlist name', + }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ + example: 'Screens to display in the morning', + description: 'Playlist description', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ + example: true, + description: 'Whether the playlist is active', + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + description: 'Screens in the playlist', + type: [PlaylistScreenDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PlaylistScreenDto) + screens?: PlaylistScreenDto[]; +} diff --git a/backend/src/playlists/playlists.controller.test.ts b/backend/src/playlists/playlists.controller.test.ts new file mode 100644 index 0000000..519fd9e --- /dev/null +++ b/backend/src/playlists/playlists.controller.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { PlaylistsController } from './playlists.controller'; +import { PlaylistsService } from './playlists.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('PlaylistsController (e2e)', () => { + let app: INestApplication; + + const mockPlaylist = { + id: 1, + name: 'Test Playlist', + description: 'A test playlist', + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + items: [], + }; + + const mockPlaylistsService = { + create: async () => mockPlaylist, + findAll: async () => ({ + items: [mockPlaylist], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }), + findOne: async () => ({ ...mockPlaylist, screens: [], devices: [] }), + update: async () => ({ ...mockPlaylist, name: 'Updated Playlist' }), + remove: async () => ({ message: 'Playlist deleted successfully', unassignedDevices: 0 }), + addItem: async () => ({ id: 1, playlistId: 1, screenId: 1, order: 0, duration: 60 }), + updateItem: async () => ({ id: 1, playlistId: 1, screenId: 1, order: 0, duration: 120 }), + removeItem: async () => ({ message: 'Playlist item removed successfully' }), + reorderItems: async () => ({ message: 'Playlist items reordered successfully' }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [PlaylistsController], + providers: [ + { provide: PlaylistsService, useValue: mockPlaylistsService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /playlists', () => { + it('should return paginated list of playlists', async () => { + const response = await request(app.getHttpServer()) + .get('/playlists') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + expect(response.body.data).toHaveProperty('total'); + }); + }); + + describe('GET /playlists/:id', () => { + it('should return a playlist by ID', async () => { + const response = await request(app.getHttpServer()) + .get('/playlists/1') + .expect(200); + + expect(response.body.data).toHaveProperty('id', 1); + expect(response.body.data).toHaveProperty('name', 'Test Playlist'); + }); + }); + + describe('POST /playlists', () => { + it('should create a new playlist', async () => { + const response = await request(app.getHttpServer()) + .post('/playlists') + .send({ name: 'New Playlist' }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('name'); + }); + }); + + describe('DELETE /playlists/:id', () => { + it('should delete a playlist', async () => { + const response = await request(app.getHttpServer()) + .delete('/playlists/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Playlist deleted successfully'); + }); + }); +}); diff --git a/backend/src/playlists/playlists.controller.ts b/backend/src/playlists/playlists.controller.ts new file mode 100644 index 0000000..527c29b --- /dev/null +++ b/backend/src/playlists/playlists.controller.ts @@ -0,0 +1,146 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseIntPipe, + Query, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { PlaylistsService } from './playlists.service'; +import { CreatePlaylistDto } from './dto/create-playlist.dto'; +import { UpdatePlaylistDto } from './dto/update-playlist.dto'; +import { AddPlaylistItemDto } from './dto/add-playlist-item.dto'; +import { UpdatePlaylistItemDto } from './dto/update-playlist-item.dto'; +import { ReorderPlaylistItemsDto } from './dto/reorder-playlist-items.dto'; + +@ApiTags('playlists') +@ApiBearerAuth('access-token') +@Controller('playlists') +export class PlaylistsController { + constructor(private readonly playlistsService: PlaylistsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new playlist' }) + @ApiResponse({ status: 201, description: 'Playlist successfully created' }) + create(@Body() createPlaylistDto: CreatePlaylistDto) { + return this.playlistsService.create(createPlaylistDto); + } + + @Get() + @ApiOperation({ summary: 'Get all playlists with pagination' }) + @ApiResponse({ status: 200, description: 'List of playlists' }) + findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = Math.max(parseInt(page || '1', 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit || '20', 10) || 20, 1), 100); + return this.playlistsService.findAll(pageNum, limitNum); + } + + @Get(':id') + @ApiOperation({ summary: 'Get playlist by ID' }) + @ApiResponse({ status: 200, description: 'Playlist details' }) + @ApiResponse({ status: 404, description: 'Playlist not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.playlistsService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update playlist' }) + @ApiResponse({ status: 200, description: 'Playlist successfully updated' }) + @ApiResponse({ status: 404, description: 'Playlist not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updatePlaylistDto: UpdatePlaylistDto, + ) { + return this.playlistsService.update(id, updatePlaylistDto); + } + + @Delete(':id') + @ApiOperation({ + summary: 'Delete playlist', + description: + 'Deletes a playlist and all its items. If playlist is assigned to devices, ' + + 'use force=true to unassign from all devices first (devices will display default screen).', + }) + @ApiQuery({ + name: 'force', + required: false, + type: Boolean, + description: + 'If true, unassigns playlist from all devices before deletion. ' + + 'Devices will be set to display the default "Hello World" screen.', + }) + @ApiResponse({ status: 200, description: 'Playlist successfully deleted' }) + @ApiResponse({ status: 404, description: 'Playlist not found' }) + @ApiResponse({ + status: 400, + description: 'Playlist is in use by devices (use force=true to unassign and delete)', + }) + remove( + @Param('id', ParseIntPipe) id: number, + @Query('force') force: string, + ) { + const forceDelete = force === 'true' || force === '1'; + return this.playlistsService.remove(id, forceDelete); + } + + // Playlist Items Management + + @Post(':id/items') + @ApiOperation({ summary: 'Add screen to playlist' }) + @ApiResponse({ status: 201, description: 'Screen added to playlist' }) + @ApiResponse({ status: 404, description: 'Playlist or screen not found' }) + @ApiResponse({ status: 400, description: 'Screen already in playlist' }) + addItem( + @Param('id', ParseIntPipe) id: number, + @Body() addItemDto: AddPlaylistItemDto, + ) { + return this.playlistsService.addItem(id, addItemDto); + } + + @Patch(':id/items/:itemId') + @ApiOperation({ summary: 'Update playlist item' }) + @ApiResponse({ status: 200, description: 'Playlist item updated' }) + @ApiResponse({ status: 404, description: 'Playlist or item not found' }) + updateItem( + @Param('id', ParseIntPipe) id: number, + @Param('itemId', ParseIntPipe) itemId: number, + @Body() updateItemDto: UpdatePlaylistItemDto, + ) { + return this.playlistsService.updateItem(id, itemId, updateItemDto); + } + + @Delete(':id/items/:itemId') + @ApiOperation({ summary: 'Remove screen from playlist' }) + @ApiResponse({ status: 200, description: 'Screen removed from playlist' }) + @ApiResponse({ status: 404, description: 'Playlist or item not found' }) + removeItem( + @Param('id', ParseIntPipe) id: number, + @Param('itemId', ParseIntPipe) itemId: number, + ) { + return this.playlistsService.removeItem(id, itemId); + } + + @Post(':id/reorder') + @ApiOperation({ summary: 'Reorder playlist items' }) + @ApiResponse({ status: 200, description: 'Playlist items reordered' }) + @ApiResponse({ status: 404, description: 'Playlist not found' }) + reorderItems( + @Param('id', ParseIntPipe) id: number, + @Body() reorderDto: ReorderPlaylistItemsDto, + ) { + return this.playlistsService.reorderItems(id, reorderDto.items); + } +} diff --git a/backend/src/playlists/playlists.module.ts b/backend/src/playlists/playlists.module.ts new file mode 100644 index 0000000..31c2984 --- /dev/null +++ b/backend/src/playlists/playlists.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PlaylistsController } from './playlists.controller'; +import { PlaylistsService } from './playlists.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [PlaylistsController], + providers: [PlaylistsService], + exports: [PlaylistsService], +}) +export class PlaylistsModule {} diff --git a/backend/src/playlists/playlists.service.test.ts b/backend/src/playlists/playlists.service.test.ts new file mode 100644 index 0000000..7e77539 --- /dev/null +++ b/backend/src/playlists/playlists.service.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { PlaylistsService } from './playlists.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; +import { createMock } from '../test/mocks/helpers'; + +describe('PlaylistsService', () => { + let service: PlaylistsService; + let mockPrisma: MockPrisma; + let mockEventsService: { + notifyDevicesRefresh: ReturnType; + notifyPlaylistUpdate: ReturnType; + }; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + // Add createMany (not in default mock) - used by playlist item batch creation + (mockPrisma.playlistItem as any).createMany = createMock(); + mockEventsService = { + notifyDevicesRefresh: createMock().mockResolvedValue(undefined), + notifyPlaylistUpdate: createMock().mockResolvedValue(undefined), + }; + service = new PlaylistsService(mockPrisma as any, mockEventsService as any); + }); + + // ─── create ───────────────────────────────────────────────────────── + + describe('create()', () => { + it('should create a basic playlist without screens', async () => { + const playlist = { + id: 1, + name: 'My Playlist', + description: 'Test', + isActive: true, + items: [], + }; + mockPrisma.playlist.create.mockResolvedValue(playlist); + + const result = await service.create({ name: 'My Playlist', description: 'Test' } as any); + + expect(result).toEqual(playlist); + expect(mockPrisma.playlist.create.calls).toHaveLength(1); + }); + + it('should create playlist with screens and verify items', async () => { + const playlist = { id: 1, name: 'With Screens', isActive: true, items: [] }; + mockPrisma.playlist.create.mockResolvedValue(playlist); + mockPrisma.screen.findMany.mockResolvedValue([{ id: 10 }]); + mockPrisma.screenDesign.findMany.mockResolvedValue([]); + mockPrisma.playlistItem.createMany.mockResolvedValue({ count: 1 }); + mockPrisma.playlist.findUnique.mockResolvedValue({ + ...playlist, + items: [{ screen: { id: 10, name: 'Screen 1' }, order: 0, duration: 60 }], + }); + + const result = await service.create({ + name: 'With Screens', + screens: [{ screenId: 10, order: 0, duration: 60 }], + } as any); + + expect(mockPrisma.playlistItem.createMany.calls).toHaveLength(1); + expect(result.items).toHaveLength(1); + }); + }); + + // ─── findOne ──────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should throw NotFoundException when playlist not found', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + + it('should transform screenDesign items to screens array', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Test', + items: [ + { + screenDesign: { id: 5, name: 'Design Screen', description: 'A design' }, + screen: null, + duration: 30, + order: 0, + }, + ], + devices: [], + }); + + const result = await service.findOne(1); + + expect(result.screens).toHaveLength(1); + expect(result.screens[0].id).toBe('design-5'); + expect(result.screens[0].isDesigned).toBe(true); + expect(result.screens[0].imageUrl).toContain('/api/device-images/design/5'); + }); + + it('should transform regular screen items to screens array', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Test', + items: [ + { + screenDesign: null, + screen: { + id: 10, + name: 'Regular Screen', + description: 'desc', + thumbnailUrl: '/thumb.png', + imageUrl: '/image.png', + }, + duration: 60, + order: 0, + }, + ], + devices: [], + }); + + const result = await service.findOne(1); + + expect(result.screens).toHaveLength(1); + expect(result.screens[0].id).toBe(10); + expect(result.screens[0].isDesigned).toBe(false); + expect(result.screens[0].imageUrl).toBe('/image.png'); + }); + }); + + // ─── update ───────────────────────────────────────────────────────── + + describe('update()', () => { + it('should throw NotFoundException when playlist not found', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue(null); + await expect(service.update(999, { name: 'Updated' } as any)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should prevent deactivation when devices are assigned', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Active Playlist', + _count: { devices: 3 }, + }); + + await expect( + service.update(1, { isActive: false } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should update screens by deleting old and creating new items', async () => { + mockPrisma.playlist.findUnique + .mockImplementation((...args: any[]) => { + // First call: check existence + if (mockPrisma.playlist.findUnique.calls.length <= 1) { + return Promise.resolve({ + id: 1, + name: 'Playlist', + _count: { devices: 0 }, + }); + } + // Second call: refetch after update + return Promise.resolve({ + id: 1, + name: 'Playlist', + items: [ + { + screen: { id: 20, name: 'New Screen', description: null, thumbnailUrl: null, imageUrl: '/new.png' }, + screenDesign: null, + duration: 45, + order: 0, + }, + ], + }); + }); + + mockPrisma.playlist.update.mockResolvedValue({ + id: 1, + name: 'Playlist', + items: [], + }); + mockPrisma.playlistItem.deleteMany.mockResolvedValue({ count: 2 }); + mockPrisma.screen.findMany.mockResolvedValue([{ id: 20 }]); + mockPrisma.screenDesign.findMany.mockResolvedValue([]); + mockPrisma.playlistItem.createMany.mockResolvedValue({ count: 1 }); + + await service.update(1, { + name: 'Playlist', + screens: [{ screenId: 20, order: 0, duration: 45 }], + } as any); + + expect(mockPrisma.playlistItem.deleteMany.calls).toHaveLength(1); + expect(mockPrisma.playlistItem.createMany.calls).toHaveLength(1); + expect(mockEventsService.notifyPlaylistUpdate.calls).toHaveLength(1); + }); + + it('should notify playlist update even without screen changes', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Playlist', + _count: { devices: 0 }, + }); + mockPrisma.playlist.update.mockResolvedValue({ + id: 1, + name: 'Renamed', + items: [], + }); + + await service.update(1, { name: 'Renamed' } as any); + + expect(mockEventsService.notifyPlaylistUpdate.calls).toHaveLength(1); + }); + }); + + // ─── remove ───────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should throw NotFoundException when playlist not found', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when devices assigned without force', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'In Use', + devices: [{ id: 1 }], + _count: { devices: 1 }, + }); + + await expect(service.remove(1, false)).rejects.toThrow(BadRequestException); + }); + + it('should force-delete by unassigning devices first', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Force Delete', + devices: [{ id: 10 }, { id: 20 }], + _count: { devices: 2 }, + }); + mockPrisma.device.updateMany.mockResolvedValue({ count: 2 }); + mockPrisma.playlist.delete.mockResolvedValue({}); + + const result = await service.remove(1, true); + + expect(mockPrisma.device.updateMany.calls).toHaveLength(1); + expect(mockPrisma.device.updateMany.calls[0][0].data.playlistId).toBeNull(); + expect(mockEventsService.notifyDevicesRefresh.calls).toHaveLength(1); + expect(mockEventsService.notifyDevicesRefresh.calls[0][0]).toEqual([10, 20]); + expect(mockPrisma.playlist.delete.calls).toHaveLength(1); + expect(result.unassignedDevices).toBe(2); + }); + + it('should delete playlist with no assigned devices', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Empty', + devices: [], + _count: { devices: 0 }, + }); + mockPrisma.playlist.delete.mockResolvedValue({}); + + const result = await service.remove(1); + + expect(mockPrisma.playlist.delete.calls).toHaveLength(1); + expect(result.message).toBe('Playlist deleted successfully'); + expect(result.unassignedDevices).toBe(0); + }); + }); + + // ─── addItem ──────────────────────────────────────────────────────── + + describe('addItem()', () => { + it('should throw NotFoundException when playlist not found', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue(null); + + await expect( + service.addItem(999, { screenId: 1 } as any), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException when screen not found', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Playlist', + items: [], + }); + mockPrisma.screen.findUnique.mockResolvedValue(null); + + await expect( + service.addItem(1, { screenId: 999 } as any), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when screen already in playlist', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Playlist', + items: [], + }); + mockPrisma.screen.findUnique.mockResolvedValue({ id: 10, name: 'Screen' }); + mockPrisma.playlistItem.findFirst.mockResolvedValue({ + id: 1, + playlistId: 1, + screenId: 10, + }); + + await expect( + service.addItem(1, { screenId: 10 } as any), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── reorderItems ────────────────────────────────────────────────── + + describe('reorderItems()', () => { + it('should throw NotFoundException when playlist not found', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue(null); + + await expect( + service.reorderItems(999, [{ id: 1, order: 0 }]), + ).rejects.toThrow(NotFoundException); + }); + + it('should update order via transaction', async () => { + mockPrisma.playlist.findUnique.mockResolvedValue({ + id: 1, + name: 'Playlist', + }); + // $transaction receives an array of promises when called with array argument + mockPrisma.$transaction.mockResolvedValue([{}, {}]); + + const result = await service.reorderItems(1, [ + { id: 10, order: 0 }, + { id: 20, order: 1 }, + ]); + + expect(mockPrisma.$transaction.calls).toHaveLength(1); + expect(result.message).toBe('Playlist items reordered successfully'); + expect(mockEventsService.notifyPlaylistUpdate.calls).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/playlists/playlists.service.ts b/backend/src/playlists/playlists.service.ts new file mode 100644 index 0000000..51835a4 --- /dev/null +++ b/backend/src/playlists/playlists.service.ts @@ -0,0 +1,727 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePlaylistDto } from './dto/create-playlist.dto'; +import { UpdatePlaylistDto } from './dto/update-playlist.dto'; +import { AddPlaylistItemDto } from './dto/add-playlist-item.dto'; +import { UpdatePlaylistItemDto } from './dto/update-playlist-item.dto'; +import { wrapPaginatedResponse } from '../common/utils/response.util'; +import { EventsService } from '../events/events.service'; + +@Injectable() +export class PlaylistsService { + private readonly logger = new Logger(PlaylistsService.name); + + constructor( + private prisma: PrismaService, + private eventsService: EventsService, + ) {} + + /** + * Create a new playlist + */ + async create(createPlaylistDto: CreatePlaylistDto) { + const { screens, ...playlistData } = createPlaylistDto; + + const playlist = await this.prisma.playlist.create({ + data: { + name: playlistData.name, + description: playlistData.description, + isActive: playlistData.isActive ?? true, + }, + include: { + items: { + include: { + screen: true, + screenDesign: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }); + + // Handle screens if provided + if (screens && screens.length > 0) { + // Parse and categorize screen IDs to avoid N+1 queries + const designIds: number[] = []; + const regularIds: number[] = []; + const screenMap = new Map(); + + screens.forEach((screenData, i) => { + if (typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-')) { + const designId = parseInt(screenData.screenId.replace('design-', ''), 10); + if (!isNaN(designId)) { + designIds.push(designId); + screenMap.set(screenData.screenId, { + type: 'design', + id: designId, + order: screenData.order ?? i, + duration: screenData.duration ?? 60, + }); + } else { + this.logger.warn(`Invalid screen design ID: ${screenData.screenId}`); + } + } else { + const screenId = typeof screenData.screenId === 'string' + ? parseInt(screenData.screenId, 10) + : screenData.screenId as unknown as number; + if (!isNaN(screenId)) { + regularIds.push(screenId); + screenMap.set(String(screenId), { + type: 'regular', + id: screenId, + order: screenData.order ?? i, + duration: screenData.duration ?? 60, + }); + } else { + this.logger.warn(`Invalid screenId: ${screenData.screenId}`); + } + } + }); + + // Batch verify existence of all screens/designs + const [existingDesigns, existingScreens] = await Promise.all([ + designIds.length > 0 + ? this.prisma.screenDesign.findMany({ where: { id: { in: designIds } }, select: { id: true } }) + : Promise.resolve([]), + regularIds.length > 0 + ? this.prisma.screen.findMany({ where: { id: { in: regularIds } }, select: { id: true } }) + : Promise.resolve([]), + ]); + + const existingDesignIds = new Set(existingDesigns.map((d) => d.id)); + const existingScreenIds = new Set(existingScreens.map((s) => s.id)); + + // Build playlist items in a single transaction + const itemsToCreate = screens + .map((screenData, i) => { + const key = typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-') + ? screenData.screenId + : String(typeof screenData.screenId === 'string' ? parseInt(screenData.screenId, 10) : screenData.screenId); + const mapped = screenMap.get(key); + if (!mapped) return null; + + if (mapped.type === 'design') { + if (!existingDesignIds.has(mapped.id)) { + this.logger.warn(`Screen design not found: ${mapped.id}`); + return null; + } + return { + playlistId: playlist.id, + screenDesignId: mapped.id, + order: mapped.order, + duration: mapped.duration, + }; + } else { + if (!existingScreenIds.has(mapped.id)) { + this.logger.warn(`Screen not found: ${mapped.id}`); + return null; + } + return { + playlistId: playlist.id, + screenId: mapped.id, + order: mapped.order, + duration: mapped.duration, + }; + } + }) + .filter((item): item is NonNullable => item !== null); + + if (itemsToCreate.length > 0) { + await this.prisma.playlistItem.createMany({ data: itemsToCreate }); + } + + // Refetch playlist with items + const updatedPlaylist = await this.prisma.playlist.findUnique({ + where: { id: playlist.id }, + include: { + items: { + include: { + screen: true, + screenDesign: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }); + + this.logger.log(`Playlist created: ${playlist.name} with ${itemsToCreate.length} screens`); + + return updatedPlaylist; + } + + this.logger.log(`Playlist created: ${playlist.name}`); + + return playlist; + } + + /** + * Find all playlists with pagination + */ + async findAll(page = 1, limit = 20) { + const skip = (page - 1) * limit; + + const [playlists, total] = await Promise.all([ + this.prisma.playlist.findMany({ + include: { + _count: { + select: { + items: true, + devices: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + }), + this.prisma.playlist.count(), + ]); + + return wrapPaginatedResponse(playlists, total, page, limit); + } + + /** + * Find one playlist by ID + */ + async findOne(id: number) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id }, + include: { + items: { + include: { + screen: { + include: { + model: true, + }, + }, + screenDesign: true, + }, + orderBy: { + order: 'asc', + }, + }, + devices: { + select: { + id: true, + name: true, + macAddress: true, + width: true, + height: true, + }, + }, + }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Transform items to screens array for frontend compatibility + const screens = playlist.items.map((item) => { + if (item.screenDesign) { + // Generate preview URL for designed screens (preview=true skips e-ink processing) + const previewUrl = `/api/device-images/design/${item.screenDesign.id}?preview=true`; + return { + id: `design-${item.screenDesign.id}`, + screenId: `design-${item.screenDesign.id}`, + name: item.screenDesign.name, + description: item.screenDesign.description, + thumbnailUrl: previewUrl, + imageUrl: previewUrl, + duration: item.duration, + order: item.order, + isDesigned: true, + }; + } else if (item.screen) { + return { + id: item.screen.id, + screenId: String(item.screen.id), + name: item.screen.name, + description: item.screen.description, + thumbnailUrl: item.screen.thumbnailUrl, + imageUrl: item.screen.imageUrl, + duration: item.duration, + order: item.order, + isDesigned: false, + }; + } + return null; + }).filter(Boolean); + + return { + ...playlist, + screens, + }; + } + + /** + * Update playlist + */ + async update(id: number, updatePlaylistDto: UpdatePlaylistDto) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id }, + include: { + _count: { + select: { devices: true }, + }, + }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Prevent deactivating a playlist that has devices assigned + if (updatePlaylistDto.isActive === false && playlist._count.devices > 0) { + throw new BadRequestException( + `Cannot deactivate playlist - it is assigned to ${playlist._count.devices} device(s). Unassign all devices first.`, + ); + } + + // Extract screens for separate handling + const { screens, ...playlistData } = updatePlaylistDto; + + // Update playlist basic data + const updatedPlaylist = await this.prisma.playlist.update({ + where: { id }, + data: { + name: playlistData.name, + description: playlistData.description, + isActive: playlistData.isActive, + }, + include: { + items: { + include: { + screen: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }); + + // Handle screens update if provided + if (screens !== undefined) { + // Delete all existing items + await this.prisma.playlistItem.deleteMany({ + where: { playlistId: id }, + }); + + // Add new items using batch approach to avoid N+1 queries + if (screens.length > 0) { + // Parse and categorize screen IDs + const designIds: number[] = []; + const regularIds: number[] = []; + const screenMap = new Map(); + + screens.forEach((screenData, i) => { + if (typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-')) { + const designId = parseInt(screenData.screenId.replace('design-', ''), 10); + if (!isNaN(designId)) { + designIds.push(designId); + screenMap.set(screenData.screenId, { + type: 'design', + id: designId, + order: screenData.order ?? i, + duration: screenData.duration ?? 60, + }); + } else { + this.logger.warn(`Invalid screen design ID: ${screenData.screenId}`); + } + } else { + const screenId = typeof screenData.screenId === 'string' + ? parseInt(screenData.screenId, 10) + : screenData.screenId as unknown as number; + if (!isNaN(screenId)) { + regularIds.push(screenId); + screenMap.set(String(screenId), { + type: 'regular', + id: screenId, + order: screenData.order ?? i, + duration: screenData.duration ?? 60, + }); + } else { + this.logger.warn(`Invalid screenId: ${screenData.screenId}`); + } + } + }); + + // Batch verify existence of all screens/designs + const [existingDesigns, existingScreens] = await Promise.all([ + designIds.length > 0 + ? this.prisma.screenDesign.findMany({ where: { id: { in: designIds } }, select: { id: true } }) + : Promise.resolve([]), + regularIds.length > 0 + ? this.prisma.screen.findMany({ where: { id: { in: regularIds } }, select: { id: true } }) + : Promise.resolve([]), + ]); + + const existingDesignIds = new Set(existingDesigns.map((d) => d.id)); + const existingScreenIds = new Set(existingScreens.map((s) => s.id)); + + // Build playlist items in a single batch + const itemsToCreate = screens + .map((screenData, i) => { + const key = typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-') + ? screenData.screenId + : String(typeof screenData.screenId === 'string' ? parseInt(screenData.screenId, 10) : screenData.screenId); + const mapped = screenMap.get(key); + if (!mapped) return null; + + if (mapped.type === 'design') { + if (!existingDesignIds.has(mapped.id)) { + this.logger.warn(`Screen design not found: ${mapped.id}`); + return null; + } + return { + playlistId: id, + screenDesignId: mapped.id, + order: mapped.order, + duration: mapped.duration, + }; + } else { + if (!existingScreenIds.has(mapped.id)) { + this.logger.warn(`Screen not found: ${mapped.id}`); + return null; + } + return { + playlistId: id, + screenId: mapped.id, + order: mapped.order, + duration: mapped.duration, + }; + } + }) + .filter((item): item is NonNullable => item !== null); + + if (itemsToCreate.length > 0) { + await this.prisma.playlistItem.createMany({ data: itemsToCreate }); + } + } + + // Refetch playlist with updated items and transform to screens array + const updatedPlaylistWithItems = await this.prisma.playlist.findUnique({ + where: { id }, + include: { + items: { + include: { + screen: true, + screenDesign: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }); + + if (!updatedPlaylistWithItems) { + throw new NotFoundException('Playlist not found after update'); + } + + // Transform items to screens array + const transformedScreens = updatedPlaylistWithItems.items.map((item) => { + if (item.screenDesign) { + // Generate preview URL for designed screens (preview=true skips e-ink processing) + const previewUrl = `/api/device-images/design/${item.screenDesign.id}?preview=true`; + return { + id: `design-${item.screenDesign.id}`, + screenId: `design-${item.screenDesign.id}`, + name: item.screenDesign.name, + description: item.screenDesign.description, + thumbnailUrl: previewUrl, + imageUrl: previewUrl, + duration: item.duration, + order: item.order, + isDesigned: true, + }; + } else if (item.screen) { + return { + id: item.screen.id, + screenId: String(item.screen.id), + name: item.screen.name, + description: item.screen.description, + thumbnailUrl: item.screen.thumbnailUrl, + imageUrl: item.screen.imageUrl, + duration: item.duration, + order: item.order, + isDesigned: false, + }; + } + return null; + }).filter(Boolean); + + // Notify devices that use this playlist to refresh + await this.eventsService.notifyPlaylistUpdate(id); + + return { + ...updatedPlaylistWithItems, + screens: transformedScreens, + }; + } + + this.logger.log(`Playlist updated: ${updatedPlaylist.name}`); + + // Notify devices that use this playlist to refresh + await this.eventsService.notifyPlaylistUpdate(id); + + return updatedPlaylist; + } + + /** + * Delete playlist + * @param force - If true, unassign all devices first and then delete + */ + async remove(id: number, force = false) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id }, + include: { + devices: { + select: { id: true }, + }, + _count: { + select: { + devices: true, + }, + }, + }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Check if playlist is in use + if (playlist._count.devices > 0) { + if (force) { + // Unassign all devices first + const deviceIds = playlist.devices.map((d) => d.id); + await this.prisma.device.updateMany({ + where: { playlistId: id }, + data: { + playlistId: null, + refreshPending: true, // Trigger refresh to show default screen + }, + }); + this.logger.log( + `Force delete: Unassigned ${deviceIds.length} device(s) from playlist ${playlist.name}`, + ); + + // Notify devices to refresh + await this.eventsService.notifyDevicesRefresh(deviceIds); + } else { + throw new BadRequestException( + `Cannot delete playlist - it is assigned to ${playlist._count.devices} device(s). Use force=true to unassign devices and delete.`, + ); + } + } + + await this.prisma.playlist.delete({ + where: { id }, + }); + + this.logger.log(`Playlist deleted: ${playlist.name}`); + + return { + message: 'Playlist deleted successfully', + unassignedDevices: force ? playlist._count.devices : 0, + }; + } + + /** + * Add item to playlist + */ + async addItem(playlistId: number, addItemDto: AddPlaylistItemDto) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id: playlistId }, + include: { + items: true, + }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Check if screen exists + const screen = await this.prisma.screen.findUnique({ + where: { id: addItemDto.screenId }, + }); + + if (!screen) { + throw new NotFoundException('Screen not found'); + } + + // Check if item already exists in playlist + const existingItem = await this.prisma.playlistItem.findFirst({ + where: { + playlistId, + screenId: addItemDto.screenId, + }, + }); + + if (existingItem) { + throw new BadRequestException('Screen already in playlist'); + } + + // Determine order (append to end if not specified) + const order = + addItemDto.order !== undefined + ? addItemDto.order + : playlist.items.length; + + // Create playlist item + const item = await this.prisma.playlistItem.create({ + data: { + playlistId, + screenId: addItemDto.screenId, + order, + duration: addItemDto.duration || 60, + }, + include: { + screen: { + include: { + model: true, + }, + }, + }, + }); + + this.logger.log( + `Screen ${screen.name} added to playlist ${playlist.name}`, + ); + + // Notify devices that use this playlist to refresh + await this.eventsService.notifyPlaylistUpdate(playlistId); + + return item; + } + + /** + * Update playlist item + */ + async updateItem( + playlistId: number, + itemId: number, + updateItemDto: UpdatePlaylistItemDto, + ) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id: playlistId }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Check if item exists + const item = await this.prisma.playlistItem.findUnique({ + where: { id: itemId }, + }); + + if (!item || item.playlistId !== playlistId) { + throw new NotFoundException('Playlist item not found'); + } + + // Update item + const updatedItem = await this.prisma.playlistItem.update({ + where: { id: itemId }, + data: { + order: updateItemDto.order, + duration: updateItemDto.duration, + }, + include: { + screen: { + include: { + model: true, + }, + }, + }, + }); + + this.logger.log(`Playlist item ${itemId} updated`); + + // Notify devices that use this playlist to refresh + await this.eventsService.notifyPlaylistUpdate(playlistId); + + return updatedItem; + } + + /** + * Remove item from playlist + */ + async removeItem(playlistId: number, itemId: number) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id: playlistId }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Check if item exists + const item = await this.prisma.playlistItem.findUnique({ + where: { id: itemId }, + }); + + if (!item || item.playlistId !== playlistId) { + throw new NotFoundException('Playlist item not found'); + } + + // Delete item + await this.prisma.playlistItem.delete({ + where: { id: itemId }, + }); + + this.logger.log(`Playlist item ${itemId} removed from playlist ${playlistId}`); + + // Notify devices that use this playlist to refresh + await this.eventsService.notifyPlaylistUpdate(playlistId); + + return { message: 'Playlist item removed successfully' }; + } + + /** + * Reorder playlist items + */ + async reorderItems( + playlistId: number, + itemOrders: { id: number; order: number }[], + ) { + const playlist = await this.prisma.playlist.findUnique({ + where: { id: playlistId }, + }); + + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + + // Update orders in transaction + await this.prisma.$transaction( + itemOrders.map(({ id, order }) => + this.prisma.playlistItem.update({ + where: { id }, + data: { order }, + }), + ), + ); + + this.logger.log(`Playlist ${playlistId} items reordered`); + + // Notify devices that use this playlist to refresh + await this.eventsService.notifyPlaylistUpdate(playlistId); + + return { message: 'Playlist items reordered successfully' }; + } +} diff --git a/backend/src/prisma/prisma.module.ts b/backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..f577abe --- /dev/null +++ b/backend/src/prisma/prisma.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +/** + * Global module that provides database access throughout the application + */ +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} \ No newline at end of file diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..7fb1751 --- /dev/null +++ b/backend/src/prisma/prisma.service.ts @@ -0,0 +1,72 @@ +import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +/** + * Prisma service that manages database connection lifecycle. + * Implements OnModuleInit to connect when the module initializes + * and OnModuleDestroy to disconnect when the module is destroyed. + */ +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + log: [ + { level: 'query', emit: 'event' }, + { level: 'error', emit: 'stdout' }, + { level: 'warn', emit: 'stdout' }, + ], + }); + + // Log queries in development mode + if (process.env.NODE_ENV === 'development') { + this.$on('query' as never, (e: any) => { + this.logger.debug(`Query: ${e.query} | Duration: ${e.duration}ms`); + }); + } + } + + /** + * Connect to the database when the module initializes + */ + async onModuleInit() { + try { + await this.$connect(); + this.logger.log('Database connection established'); + } catch (error) { + this.logger.error('Failed to connect to database', error); + throw error; + } + } + + /** + * Disconnect from the database when the module is destroyed + * This ensures graceful shutdown and proper cleanup of connections + */ + async onModuleDestroy() { + try { + await this.$disconnect(); + this.logger.log('Database connection closed'); + } catch (error) { + this.logger.error('Error disconnecting from database', error); + } + } + + /** + * Clean all database tables (used for testing) + */ + async cleanDatabase() { + if (process.env.NODE_ENV === 'production') { + throw new Error('Cannot clean database in production'); + } + + const models = Reflect.ownKeys(this).filter( + (key) => key[0] !== '_' && key !== '$connect' && key !== '$disconnect', + ); + + return Promise.all( + models.map((modelKey) => (this as any)[modelKey].deleteMany()), + ); + } +} \ No newline at end of file diff --git a/backend/src/screen-designer/dto/assign-device.dto.ts b/backend/src/screen-designer/dto/assign-device.dto.ts new file mode 100644 index 0000000..58d7e28 --- /dev/null +++ b/backend/src/screen-designer/dto/assign-device.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt } from 'class-validator'; + +export class AssignDeviceDto { + @ApiProperty({ + example: 1, + description: 'ID of the device to assign', + }) + @IsInt() + deviceId: number; +} diff --git a/backend/src/screen-designer/dto/create-screen-design.dto.ts b/backend/src/screen-designer/dto/create-screen-design.dto.ts new file mode 100644 index 0000000..01233ed --- /dev/null +++ b/backend/src/screen-designer/dto/create-screen-design.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsInt, + IsBoolean, + IsArray, + ValidateNested, + Min, + Max, + IsHexColor, + MaxLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CreateWidgetDto } from './create-widget.dto'; + +export class CreateScreenDesignDto { + @ApiProperty({ + example: 'My Dashboard', + description: 'Name of the screen design', + }) + @IsString() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ + example: 'A custom dashboard with clock and weather', + description: 'Description of the screen design', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ + example: 800, + description: 'Width of the screen design in pixels', + default: 800, + }) + @IsOptional() + @IsInt() + @Min(100) + @Max(2000) + width?: number; + + @ApiPropertyOptional({ + example: 480, + description: 'Height of the screen design in pixels', + default: 480, + }) + @IsOptional() + @IsInt() + @Min(100) + @Max(2000) + height?: number; + + @ApiPropertyOptional({ + example: '#FFFFFF', + description: 'Background color (hex)', + default: '#FFFFFF', + }) + @IsOptional() + @IsHexColor() + background?: string; + + @ApiPropertyOptional({ + example: false, + description: 'Whether this design is a template for others to use', + default: false, + }) + @IsOptional() + @IsBoolean() + isTemplate?: boolean; + + @ApiPropertyOptional({ + type: [CreateWidgetDto], + description: 'Initial widgets to add to the screen design', + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateWidgetDto) + widgets?: CreateWidgetDto[]; +} diff --git a/backend/src/screen-designer/dto/create-widget.dto.ts b/backend/src/screen-designer/dto/create-widget.dto.ts new file mode 100644 index 0000000..daf786b --- /dev/null +++ b/backend/src/screen-designer/dto/create-widget.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsOptional, + IsObject, + Min, + Max, +} from 'class-validator'; + +export class CreateWidgetDto { + @ApiProperty({ + example: 1, + description: 'ID of the widget template to use', + }) + @IsInt() + templateId: number; + + @ApiPropertyOptional({ + example: 0, + description: 'X position of the widget on the canvas (can be negative for overflow effects)', + default: 0, + }) + @IsOptional() + @IsInt() + x?: number; + + @ApiPropertyOptional({ + example: 0, + description: 'Y position of the widget on the canvas (can be negative for overflow effects)', + default: 0, + }) + @IsOptional() + @IsInt() + y?: number; + + @ApiPropertyOptional({ + example: 200, + description: 'Width of the widget in pixels', + default: 200, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(2000) + width?: number; + + @ApiPropertyOptional({ + example: 100, + description: 'Height of the widget in pixels', + default: 100, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(2000) + height?: number; + + @ApiPropertyOptional({ + example: { timezone: 'America/New_York', format: '12h' }, + description: 'Widget-specific configuration', + }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional({ + example: 0, + description: 'Z-index for layering widgets', + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + zIndex?: number; + + @ApiPropertyOptional({ + example: 0, + description: 'Rotation angle in degrees (0-360)', + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(360) + rotation?: number; +} diff --git a/backend/src/screen-designer/dto/index.ts b/backend/src/screen-designer/dto/index.ts new file mode 100644 index 0000000..f06853a --- /dev/null +++ b/backend/src/screen-designer/dto/index.ts @@ -0,0 +1,5 @@ +export * from './create-screen-design.dto'; +export * from './update-screen-design.dto'; +export * from './create-widget.dto'; +export * from './update-widget.dto'; +export * from './assign-device.dto'; diff --git a/backend/src/screen-designer/dto/update-screen-design.dto.ts b/backend/src/screen-designer/dto/update-screen-design.dto.ts new file mode 100644 index 0000000..241060f --- /dev/null +++ b/backend/src/screen-designer/dto/update-screen-design.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateScreenDesignDto } from './create-screen-design.dto'; + +export class UpdateScreenDesignDto extends PartialType(CreateScreenDesignDto) {} diff --git a/backend/src/screen-designer/dto/update-widget.dto.ts b/backend/src/screen-designer/dto/update-widget.dto.ts new file mode 100644 index 0000000..40c1137 --- /dev/null +++ b/backend/src/screen-designer/dto/update-widget.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from '@nestjs/swagger'; +import { CreateWidgetDto } from './create-widget.dto'; + +export class UpdateWidgetDto extends PartialType( + OmitType(CreateWidgetDto, ['templateId'] as const), +) {} diff --git a/backend/src/screen-designer/index.ts b/backend/src/screen-designer/index.ts new file mode 100644 index 0000000..d4fa220 --- /dev/null +++ b/backend/src/screen-designer/index.ts @@ -0,0 +1,6 @@ +export * from './screen-designer.module'; +export * from './screen-designer.service'; +export * from './screen-designer.controller'; +export * from './services/widget-templates.service'; +export * from './services/screen-renderer.service'; +export * from './dto'; diff --git a/backend/src/screen-designer/screen-designer.controller.ts b/backend/src/screen-designer/screen-designer.controller.ts new file mode 100644 index 0000000..b084f4c --- /dev/null +++ b/backend/src/screen-designer/screen-designer.controller.ts @@ -0,0 +1,833 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + ParseIntPipe, + UseInterceptors, + UploadedFile, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Multer } from 'multer'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as sharpModule from 'sharp'; +// Handle both ESM and CJS imports for Bun compatibility +const sharp = (sharpModule as any).default || sharpModule; + +// Max file size for widget images (90KB for e-ink devices) +const MAX_WIDGET_IMAGE_SIZE = 90000; + +// Ensure widgets upload directory exists +const widgetsUploadDir = './uploads/widgets'; +if (!fs.existsSync(widgetsUploadDir)) { + fs.mkdirSync(widgetsUploadDir, { recursive: true }); +} + +// Ensure captures upload directory exists (for pixel-perfect screen captures) +const capturesUploadDir = './uploads/captures'; +if (!fs.existsSync(capturesUploadDir)) { + fs.mkdirSync(capturesUploadDir, { recursive: true }); +} + +// Ensure drawings upload directory exists (for separate drawing overlays) +const drawingsUploadDir = './uploads/drawings'; +if (!fs.existsSync(drawingsUploadDir)) { + fs.mkdirSync(drawingsUploadDir, { recursive: true }); +} +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { ScreenDesignerService } from './screen-designer.service'; +import { WidgetTemplatesService } from './services/widget-templates.service'; +import { ScreenRendererService } from './services/screen-renderer.service'; +import { + CreateScreenDesignDto, + UpdateScreenDesignDto, + CreateWidgetDto, + UpdateWidgetDto, + AssignDeviceDto, +} from './dto'; + +@ApiTags('screen-designs') +@Controller('screen-designs') +export class ScreenDesignerController { + private readonly logger = new Logger(ScreenDesignerController.name); + + constructor( + private readonly screenDesignerService: ScreenDesignerService, + private readonly widgetTemplatesService: WidgetTemplatesService, + private readonly screenRendererService: ScreenRendererService, + ) {} + + // ======================== + // Screen Design Endpoints + // ======================== + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Create a new screen design' }) + @ApiResponse({ status: 201, description: 'Screen design created successfully' }) + async createScreenDesign(@Body() dto: CreateScreenDesignDto) { + return this.screenDesignerService.createScreenDesign(dto); + } + + @Get() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get all screen designs with pagination' }) + @ApiResponse({ status: 200, description: 'Paginated list of screen designs' }) + async getScreenDesigns( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = Math.max(parseInt(page || '1', 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit || '20', 10) || 20, 1), 100); + return this.screenDesignerService.getAllScreenDesigns(pageNum, limitNum); + } + + @Get(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get a screen design by ID' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Screen design details' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async getScreenDesign(@Param('id', ParseIntPipe) id: number) { + return this.screenDesignerService.getScreenDesign(id); + } + + @Put(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update a screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Screen design updated successfully' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async updateScreenDesign( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateScreenDesignDto, + ) { + return this.screenDesignerService.updateScreenDesign(id, dto); + } + + @Delete(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Delete a screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Screen design deleted successfully' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async deleteScreenDesign(@Param('id', ParseIntPipe) id: number) { + return this.screenDesignerService.deleteScreenDesign(id); + } + + @Post(':id/duplicate') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Duplicate a screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID to duplicate' }) + @ApiQuery({ name: 'name', required: false, description: 'Name for the duplicate' }) + @ApiResponse({ status: 201, description: 'Screen design duplicated successfully' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async duplicateScreenDesign( + @Param('id', ParseIntPipe) id: number, + @Query('name') name: string, + ) { + return this.screenDesignerService.duplicateScreenDesign(id, name); + } + + // ======================== + // Widget Endpoints + // ======================== + + @Post(':id/widgets') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Add a widget to a screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 201, description: 'Widget added successfully' }) + @ApiResponse({ status: 404, description: 'Screen design or template not found' }) + async addWidget( + @Param('id', ParseIntPipe) screenDesignId: number, + @Body() dto: CreateWidgetDto, + ) { + return this.screenDesignerService.addWidget(screenDesignId, dto); + } + + @Put(':id/widgets/:widgetId') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update a widget' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiParam({ name: 'widgetId', description: 'Widget ID' }) + @ApiResponse({ status: 200, description: 'Widget updated successfully' }) + @ApiResponse({ status: 404, description: 'Widget not found' }) + async updateWidget( + @Param('id', ParseIntPipe) screenDesignId: number, + @Param('widgetId', ParseIntPipe) widgetId: number, + @Body() dto: UpdateWidgetDto, + ) { + return this.screenDesignerService.updateWidget(screenDesignId, widgetId, dto); + } + + @Delete(':id/widgets/:widgetId') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Remove a widget from a screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiParam({ name: 'widgetId', description: 'Widget ID' }) + @ApiResponse({ status: 200, description: 'Widget removed successfully' }) + @ApiResponse({ status: 404, description: 'Widget not found' }) + async removeWidget( + @Param('id', ParseIntPipe) screenDesignId: number, + @Param('widgetId', ParseIntPipe) widgetId: number, + ) { + return this.screenDesignerService.removeWidget(screenDesignId, widgetId); + } + + // ======================== + // Device Assignment Endpoints + // ======================== + + @Post(':id/assign') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Assign a screen design to a device' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 201, description: 'Screen design assigned to device' }) + @ApiResponse({ status: 404, description: 'Screen design or device not found' }) + async assignToDevice( + @Param('id', ParseIntPipe) screenDesignId: number, + @Body() dto: AssignDeviceDto, + ) { + return this.screenDesignerService.assignToDevice(screenDesignId, dto); + } + + @Delete(':id/assign/:deviceId') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Unassign a screen design from a device' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiParam({ name: 'deviceId', description: 'Device ID' }) + @ApiResponse({ status: 200, description: 'Screen design unassigned from device' }) + @ApiResponse({ status: 404, description: 'Assignment not found' }) + async unassignFromDevice( + @Param('id', ParseIntPipe) screenDesignId: number, + @Param('deviceId', ParseIntPipe) deviceId: number, + ) { + return this.screenDesignerService.unassignFromDevice(screenDesignId, deviceId); + } + + // ======================== + // Rendering Endpoint + // ======================== + + @Get(':id/render') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Render a screen design to PNG image' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ + status: 200, + description: 'PNG image of the rendered screen design', + content: { + 'image/png': {}, + }, + }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async renderScreenDesign(@Param('id', ParseIntPipe) id: number) { + // Verify design exists + await this.screenDesignerService.getScreenDesign(id); + + // Then render + const imageBuffer = await this.screenRendererService.renderScreenDesign(id); + + return { + contentType: 'image/png', + data: imageBuffer.toString('base64'), + }; + } + + @Get(':id/preview') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get a preview thumbnail of the screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Preview thumbnail' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async getPreview(@Param('id', ParseIntPipe) id: number) { + // Verify design exists + await this.screenDesignerService.getScreenDesign(id); + + // Then render preview + const imageBuffer = await this.screenRendererService.renderPreview(id); + + return { + contentType: 'image/png', + data: imageBuffer.toString('base64'), + }; + } + + @Post(':id/refresh-devices') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Refresh all devices using this screen design' }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Devices refresh triggered' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async refreshDevices(@Param('id', ParseIntPipe) id: number) { + return this.screenDesignerService.refreshDevicesUsingDesign(id); + } + + // ======================== + // Pixel-Perfect Capture Endpoint + // ======================== + + @Post(':id/capture') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Render and save screen capture for device (legacy)', + description: 'Renders the screen design server-side with Puppeteer, applies e-ink processing (dithering + inversion), and saves for device display. Use upload-capture for pixel-perfect browser rendering.', + }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 201, description: 'Capture rendered and saved' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + async captureForDevice(@Param('id', ParseIntPipe) id: number) { + // Verify the screen design exists + await this.screenDesignerService.getScreenDesign(id); + + // Render the design using Puppeteer with full e-ink processing + // 'device' mode applies: dithering + inversion for TRMNL + const imageBuffer = await this.screenRendererService.renderScreenDesign( + id, + undefined, // No device context needed for capture + 'device', // Full e-ink processing + ); + + // Save to captures directory + const filename = `capture_${id}.png`; + const filepath = path.join(capturesUploadDir, filename); + await fs.promises.writeFile(filepath, imageBuffer); + + const captureUrl = `/uploads/captures/${filename}`; + + // Update the screen design with capture timestamp + await this.screenDesignerService.updateCaptureTimestamp(id); + + return { + captureUrl, + filename, + size: imageBuffer.length, + }; + } + + @Post(':id/upload-capture') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Upload browser-captured B&W image for device', + description: 'Receives a PNG with Floyd-Steinberg dithering already applied from browser. Only inverts colors for TRMNL e-ink device and saves.', + }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 201, description: 'Capture saved for device' }) + @ApiResponse({ status: 400, description: 'Invalid file type or no file uploaded' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + @UseInterceptors( + FileInterceptor('image', { + fileFilter: (req, file, cb) => { + // Accept only PNG files + if (file.mimetype === 'image/png') { + cb(null, true); + } else { + cb(new BadRequestException('Only PNG files are allowed'), false); + } + }, + limits: { + fileSize: 10 * 1024 * 1024, // 10MB max + }, + }), + ) + async uploadCapture( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: Multer.File, + ) { + // Verify the screen design exists + await this.screenDesignerService.getScreenDesign(id); + + if (!file) { + throw new BadRequestException('No file uploaded. Make sure to send file with field name "image"'); + } + + this.logger.log(`Saving browser capture for screen ${id}: ${file.size} bytes`); + + try { + // Simple e-ink processing for browser captures: + // 1. Flatten transparency to white background + // 2. Convert to grayscale + // + // NOTE: NO negate() - browser captures already have correct colors + // (white background, black content). The device displays as-is. + const processedBuffer = await sharp(file.buffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .grayscale() + .png({ compressionLevel: 9 }) + .toBuffer(); + + // Save to captures directory + const filename = `capture_${id}.png`; + const filepath = path.join(capturesUploadDir, filename); + await fs.promises.writeFile(filepath, processedBuffer); + + const captureUrl = `/uploads/captures/${filename}`; + + // Update the screen design with capture timestamp + await this.screenDesignerService.updateCaptureTimestamp(id); + + this.logger.log(`Browser capture saved for screen ${id}: ${processedBuffer.length} bytes`); + + return { + captureUrl, + filename, + size: processedBuffer.length, + }; + } catch (error) { + this.logger.error('Failed to save browser capture:', error); + const message = process.env.NODE_ENV === 'production' + ? 'Image processing failed' + : `Failed to process image: ${error.message}`; + throw new BadRequestException(message); + } + } + + @Get(':id/drawing') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Get drawing overlay for a screen design', + description: 'Returns the drawing URL if a drawing overlay exists for this screen.', + }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Drawing info' }) + @ApiResponse({ status: 404, description: 'No drawing found' }) + async getDrawing(@Param('id', ParseIntPipe) id: number) { + const drawingPath = path.join(drawingsUploadDir, `drawing_${id}.png`); + + if (fs.existsSync(drawingPath)) { + const stats = await fs.promises.stat(drawingPath); + return { + exists: true, + url: `/uploads/drawings/drawing_${id}.png`, + size: stats.size, + updatedAt: stats.mtime, + }; + } + + return { + exists: false, + url: null, + }; + } + + @Delete(':id/drawing') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Delete drawing overlay for a screen design', + description: 'Removes the drawing overlay file.', + }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 200, description: 'Drawing deleted' }) + async deleteDrawing(@Param('id', ParseIntPipe) id: number) { + const drawingPath = path.join(drawingsUploadDir, `drawing_${id}.png`); + + if (fs.existsSync(drawingPath)) { + await fs.promises.unlink(drawingPath); + return { deleted: true }; + } + + return { deleted: false, message: 'No drawing to delete' }; + } + + @Post(':id/capture-with-drawing') + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Capture screen with optional drawing overlay', + description: 'Renders widgets server-side with Puppeteer, composites optional drawing on top, and applies e-ink processing.', + }) + @ApiParam({ name: 'id', description: 'Screen design ID' }) + @ApiResponse({ status: 201, description: 'Capture saved for device' }) + @ApiResponse({ status: 400, description: 'Invalid file type' }) + @ApiResponse({ status: 404, description: 'Screen design not found' }) + @UseInterceptors( + FileInterceptor('drawing', { + fileFilter: (req, file, cb) => { + // Accept only PNG files + if (file.mimetype === 'image/png') { + cb(null, true); + } else { + cb(new BadRequestException('Only PNG files are allowed'), false); + } + }, + limits: { + fileSize: 10 * 1024 * 1024, // 10MB max + }, + }), + ) + async captureWithDrawing( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() drawingFile?: Multer.File, + ) { + // Verify the screen design exists + await this.screenDesignerService.getScreenDesign(id); + + this.logger.log(`[captureWithDrawing] Screen ${id}, hasDrawing: ${!!drawingFile}`); + + try { + // Save drawing separately if provided (for compositing during dynamic renders) + if (drawingFile) { + const drawingPath = path.join(drawingsUploadDir, `drawing_${id}.png`); + await fs.promises.writeFile(drawingPath, drawingFile.buffer); + this.logger.log(`[captureWithDrawing] Drawing saved separately: ${drawingPath}`); + } + + // Render widgets using Puppeteer (preview mode - no e-ink processing yet) + const widgetsBuffer = await this.screenRendererService.renderScreenDesign( + id, + undefined, // No device context + 'preview', // Raw render without e-ink processing + ); + + this.logger.log(`[captureWithDrawing] Widgets rendered: ${widgetsBuffer.length} bytes`); + + let compositeBuffer: Buffer; + + if (drawingFile) { + this.logger.log(`[captureWithDrawing] Drawing received: ${drawingFile.size} bytes`); + + // Composite drawing on top of widgets + // Drawing has transparent background, so it overlays correctly + compositeBuffer = await sharp(widgetsBuffer) + .composite([ + { + input: drawingFile.buffer, + top: 0, + left: 0, + }, + ]) + .png() + .toBuffer(); + + this.logger.log(`[captureWithDrawing] Composite done: ${compositeBuffer.length} bytes`); + } else { + compositeBuffer = widgetsBuffer; + } + + // Apply e-ink processing: flatten, grayscale + // NO negate() - colors are correct (white bg, black content) + const processedBuffer = await sharp(compositeBuffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .grayscale() + .png({ compressionLevel: 9 }) + .toBuffer(); + + this.logger.log(`[captureWithDrawing] E-ink processed: ${processedBuffer.length} bytes`); + + // Save to captures directory + const filename = `capture_${id}.png`; + const filepath = path.join(capturesUploadDir, filename); + await fs.promises.writeFile(filepath, processedBuffer); + + const captureUrl = `/uploads/captures/${filename}`; + + // Update the screen design with capture timestamp + await this.screenDesignerService.updateCaptureTimestamp(id); + + this.logger.log(`[captureWithDrawing] Saved: ${filepath}`); + + return { + captureUrl, + filename, + size: processedBuffer.length, + }; + } catch (error) { + this.logger.error('[captureWithDrawing] Failed:', error); + const message = process.env.NODE_ENV === 'production' + ? 'Screen capture failed' + : `Failed to capture screen: ${error.message}`; + throw new BadRequestException(message); + } + } + + // ======================== + // GitHub API Proxy Endpoint + // ======================== + + @Get('github-stars/:owner/:repo') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Fetch GitHub repository stars (proxied to use token)' }) + @ApiParam({ name: 'owner', description: 'GitHub repository owner' }) + @ApiParam({ name: 'repo', description: 'GitHub repository name' }) + @ApiResponse({ status: 200, description: 'GitHub stars data' }) + async getGitHubStars( + @Param('owner') owner: string, + @Param('repo') repo: string, + ) { + return this.screenRendererService.getGitHubStars(owner, repo); + } + + // ======================== + // Widget Image Upload Endpoint + // ======================== + + @Post('upload-widget-image') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Upload an image for use in image widgets' }) + @ApiResponse({ status: 201, description: 'Image uploaded successfully' }) + @ApiResponse({ status: 400, description: 'Invalid file type' }) + @UseInterceptors( + FileInterceptor('file', { + fileFilter: (req, file, cb) => { + // Accept only image files + if (file.mimetype.match(/\/(jpg|jpeg|png|gif|bmp|webp)$/)) { + cb(null, true); + } else { + cb(new BadRequestException('Only image files are allowed'), false); + } + }, + limits: { + fileSize: 25 * 1024 * 1024, // 25MB max + }, + }), + ) + async uploadWidgetImage(@UploadedFile() file: Multer.File) { + this.logger.log(`Upload request received, file: ${file ? JSON.stringify({ name: file.originalname, size: file.size, mimetype: file.mimetype }) : 'NO FILE'}`); + + if (!file) { + throw new BadRequestException('No file uploaded. Make sure to send file with field name "file"'); + } + + const uniqueSuffix = `${Date.now()}_${crypto.randomBytes(8).toString('hex')}`; + const outputFilename = `widget_${uniqueSuffix}.png`; + const outputPath = path.join(widgetsUploadDir, outputFilename); + + try { + // Process image for e-ink: 1-bit (black/white) or 2-bit (4 grays), max 90KB + // Use in-memory buffer (no disk write before validation) + const inputBuffer = file.buffer; + let scale = 1.0; + let buffer: Buffer; + let attempts = 0; + const maxAttempts = 15; + + // Get original image metadata + const metadata = await sharp(inputBuffer).metadata(); + const originalWidth = metadata.width || 800; + const originalHeight = metadata.height || 480; + + do { + const newWidth = Math.round(originalWidth * scale); + const newHeight = Math.round(originalHeight * scale); + + // Convert to grayscale and normalize + // IMPORTANT: flatten() converts transparent backgrounds to white + // Without this, transparent pixels become black after processing + const grayBuffer = await sharp(inputBuffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .resize(newWidth, newHeight, { + fit: 'inside', + withoutEnlargement: true, + }) + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + // Apply Floyd-Steinberg dithering for 1-bit output + const { data, info } = grayBuffer; + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + pixels[i] = data[i]; + } + + // Floyd-Steinberg dithering to black/white (1-bit) + const threshold = 128; + for (let y = 0; y < info.height; y++) { + for (let x = 0; x < info.width; x++) { + const idx = y * info.width + x; + const oldPixel = pixels[idx]; + const newPixel = oldPixel < threshold ? 0 : 255; + pixels[idx] = newPixel; + const error = oldPixel - newPixel; + + if (x + 1 < info.width) { + pixels[idx + 1] += (error * 7) / 16; + } + if (x - 1 >= 0 && y + 1 < info.height) { + pixels[(y + 1) * info.width + (x - 1)] += (error * 3) / 16; + } + if (y + 1 < info.height) { + pixels[(y + 1) * info.width + x] += (error * 5) / 16; + } + if (x + 1 < info.width && y + 1 < info.height) { + pixels[(y + 1) * info.width + (x + 1)] += (error * 1) / 16; + } + } + } + + // Convert back to buffer + const output = Buffer.alloc(data.length); + for (let i = 0; i < pixels.length; i++) { + output[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + + // Create 1-bit PNG using palette mode (black and white only) + buffer = await sharp(output, { + raw: { + width: info.width, + height: info.height, + channels: 1, + }, + }) + .png({ + compressionLevel: 9, + palette: true, + colours: 2, // 1-bit: 2 colors (black/white) + }) + .toBuffer(); + + attempts++; + + // If still too large, reduce dimensions + if (buffer.length > MAX_WIDGET_IMAGE_SIZE) { + scale *= 0.85; + } + } while (buffer.length > MAX_WIDGET_IMAGE_SIZE && attempts < maxAttempts); + + // Save the processed image to disk + await fs.promises.writeFile(outputPath, buffer); + + const imageUrl = `/uploads/widgets/${outputFilename}`; + + // Return data directly - TransformInterceptor will wrap it in {data: ...} + return { + url: imageUrl, + filename: outputFilename, + originalName: file.originalname, + size: buffer.length, + compressed: buffer.length < file.size, + }; + } catch (error) { + const message = process.env.NODE_ENV === 'production' + ? 'Image processing failed' + : `Failed to process image: ${error.message}`; + throw new BadRequestException(message); + } + } + + /** + * Apply Floyd-Steinberg dithering to convert image to 1-bit (black/white) + * This is the same algorithm used in e-ink processing for optimal display + */ + private async applyFloydSteinbergDithering( + inputBuffer: Buffer, + width: number, + height: number, + ): Promise { + // Get raw grayscale pixel data + const { data, info } = await sharp(inputBuffer) + .grayscale() + .raw() + .toBuffer({ resolveWithObject: true }); + + // Create float array for error diffusion + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + pixels[i] = data[i]; + } + + // Floyd-Steinberg dithering to black/white + const threshold = 128; + for (let y = 0; y < info.height; y++) { + for (let x = 0; x < info.width; x++) { + const idx = y * info.width + x; + const oldPixel = pixels[idx]; + const newPixel = oldPixel < threshold ? 0 : 255; + pixels[idx] = newPixel; + const error = oldPixel - newPixel; + + // Distribute error to neighboring pixels + if (x + 1 < info.width) { + pixels[idx + 1] += (error * 7) / 16; + } + if (x - 1 >= 0 && y + 1 < info.height) { + pixels[(y + 1) * info.width + (x - 1)] += (error * 3) / 16; + } + if (y + 1 < info.height) { + pixels[(y + 1) * info.width + x] += (error * 5) / 16; + } + if (x + 1 < info.width && y + 1 < info.height) { + pixels[(y + 1) * info.width + (x + 1)] += (error * 1) / 16; + } + } + } + + // Convert back to buffer + const output = Buffer.alloc(data.length); + for (let i = 0; i < pixels.length; i++) { + output[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + + // Create PNG from raw grayscale data + return sharp(output, { + raw: { + width: info.width, + height: info.height, + channels: 1, + }, + }) + .png({ compressionLevel: 9 }) + .toBuffer(); + } +} + +// ======================== +// Widget Templates Controller +// ======================== + +@ApiTags('widget-templates') +@Controller('widget-templates') +export class WidgetTemplatesController { + constructor(private readonly widgetTemplatesService: WidgetTemplatesService) {} + + @Get() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get all widget templates' }) + @ApiResponse({ status: 200, description: 'List of widget templates' }) + async getAll() { + return this.widgetTemplatesService.getAll(); + } + + @Get('categories') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get all widget template categories' }) + @ApiResponse({ status: 200, description: 'List of categories' }) + async getCategories() { + return this.widgetTemplatesService.getCategories(); + } + + @Get('category/:category') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get widget templates by category' }) + @ApiParam({ name: 'category', description: 'Category name' }) + @ApiResponse({ status: 200, description: 'List of widget templates in category' }) + async getByCategory(@Param('category') category: string) { + return this.widgetTemplatesService.getByCategory(category); + } + + @Get(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get a widget template by ID' }) + @ApiParam({ name: 'id', description: 'Widget template ID' }) + @ApiResponse({ status: 200, description: 'Widget template details' }) + @ApiResponse({ status: 404, description: 'Widget template not found' }) + async getById(@Param('id', ParseIntPipe) id: number) { + return this.widgetTemplatesService.getById(id); + } +} diff --git a/backend/src/screen-designer/screen-designer.module.ts b/backend/src/screen-designer/screen-designer.module.ts new file mode 100644 index 0000000..26bd03a --- /dev/null +++ b/backend/src/screen-designer/screen-designer.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { CustomWidgetsModule } from '../custom-widgets/custom-widgets.module'; +import { SettingsModule } from '../settings/settings.module'; +import { ScreenDesignerController, WidgetTemplatesController } from './screen-designer.controller'; +import { ScreenDesignerService } from './screen-designer.service'; +import { WidgetTemplatesService } from './services/widget-templates.service'; +import { ScreenRendererService } from './services/screen-renderer.service'; + +@Module({ + imports: [PrismaModule, CustomWidgetsModule, SettingsModule], + controllers: [ScreenDesignerController, WidgetTemplatesController], + providers: [ + ScreenDesignerService, + WidgetTemplatesService, + ScreenRendererService, + ], + exports: [ + ScreenDesignerService, + WidgetTemplatesService, + ScreenRendererService, + ], +}) +export class ScreenDesignerModule {} diff --git a/backend/src/screen-designer/screen-designer.service.ts b/backend/src/screen-designer/screen-designer.service.ts new file mode 100644 index 0000000..96ab476 --- /dev/null +++ b/backend/src/screen-designer/screen-designer.service.ts @@ -0,0 +1,825 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateScreenDesignDto, + UpdateScreenDesignDto, + CreateWidgetDto, + UpdateWidgetDto, + AssignDeviceDto, +} from './dto'; +import { wrapListResponse, wrapPaginatedResponse } from '../common/utils/response.util'; +import { EventsService } from '../events/events.service'; +import { WidgetTemplatesService } from './services/widget-templates.service'; +import { CUSTOM_WIDGET_TEMPLATE_OFFSET } from '../common/constants/widget.constants'; + +@Injectable() +export class ScreenDesignerService { + private readonly logger = new Logger(ScreenDesignerService.name); + + constructor( + private prisma: PrismaService, + private eventsService: EventsService, + private widgetTemplatesService: WidgetTemplatesService, + ) {} + + /** + * Create a new screen design + */ + async createScreenDesign(dto: CreateScreenDesignDto) { + const { widgets, ...designData } = dto; + + // Check if any widgets are custom widgets (templateId >= CUSTOM_WIDGET_TEMPLATE_OFFSET) + // If so, we need to use the base template ID for the FK constraint + let customWidgetBaseTemplateId: number | null = null; + if (widgets?.some(w => w.templateId >= CUSTOM_WIDGET_TEMPLATE_OFFSET)) { + customWidgetBaseTemplateId = await this.widgetTemplatesService.getCustomWidgetBaseTemplateId(); + } + + // Map widgets, converting custom widget templateIds to base template ID + const mappedWidgets = widgets?.map((widget, index) => { + const isCustomWidget = widget.templateId >= CUSTOM_WIDGET_TEMPLATE_OFFSET; + const actualTemplateId = isCustomWidget ? customWidgetBaseTemplateId! : widget.templateId; + + // For custom widgets, ensure customWidgetId is stored in config + const config = isCustomWidget + ? { + ...(widget.config ?? {}), + customWidgetId: widget.templateId - CUSTOM_WIDGET_TEMPLATE_OFFSET, + } + : (widget.config ?? {}); + + return { + templateId: actualTemplateId, + x: widget.x ?? 0, + y: widget.y ?? 0, + width: widget.width ?? 200, + height: widget.height ?? 100, + rotation: widget.rotation ?? 0, + config, + zIndex: widget.zIndex ?? index, + }; + }); + + const screenDesign = await this.prisma.screenDesign.create({ + data: { + name: designData.name, + description: designData.description, + width: designData.width ?? 800, + height: designData.height ?? 480, + background: designData.background ?? '#FFFFFF', + isTemplate: designData.isTemplate ?? false, + widgets: mappedWidgets?.length + ? { create: mappedWidgets } + : undefined, + }, + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + }, + }); + + this.logger.log(`Screen design created: ${screenDesign.name}`); + + // Transform widgets to restore virtual template IDs for custom widgets + const transformedDesign = this.transformScreenDesignForResponse(screenDesign); + + return transformedDesign; + } + + /** + * Update a screen design + */ + async updateScreenDesign(id: number, dto: UpdateScreenDesignDto) { + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + const { widgets, ...updateData } = dto; + + // Check if any widgets are custom widgets (templateId >= CUSTOM_WIDGET_TEMPLATE_OFFSET) + let customWidgetBaseTemplateId: number | null = null; + if (widgets?.some(w => w.templateId >= CUSTOM_WIDGET_TEMPLATE_OFFSET)) { + customWidgetBaseTemplateId = await this.widgetTemplatesService.getCustomWidgetBaseTemplateId(); + } + + // Use a transaction to update design and replace all widgets atomically + const updatedDesign = await this.prisma.$transaction(async (tx) => { + // Update the screen design properties + await tx.screenDesign.update({ + where: { id }, + data: { + name: updateData.name, + description: updateData.description, + width: updateData.width, + height: updateData.height, + background: updateData.background, + isTemplate: updateData.isTemplate, + }, + }); + + // If widgets are provided, replace all existing widgets with new ones + if (widgets !== undefined) { + // Delete all existing widgets for this design + await tx.screenWidget.deleteMany({ + where: { screenDesignId: id }, + }); + + // Create new widgets if any were provided + if (widgets.length > 0) { + // Map widgets, converting custom widget templateIds to base template ID + const mappedWidgets = widgets.map((widget, index) => { + const isCustomWidget = widget.templateId >= CUSTOM_WIDGET_TEMPLATE_OFFSET; + const actualTemplateId = isCustomWidget ? customWidgetBaseTemplateId! : widget.templateId; + + // For custom widgets, ensure customWidgetId is stored in config + const config = isCustomWidget + ? { + ...(widget.config ?? {}), + customWidgetId: widget.templateId - CUSTOM_WIDGET_TEMPLATE_OFFSET, + } + : (widget.config ?? {}); + + return { + screenDesignId: id, + templateId: actualTemplateId, + x: widget.x ?? 0, + y: widget.y ?? 0, + width: widget.width ?? 200, + height: widget.height ?? 100, + rotation: widget.rotation ?? 0, + config, + zIndex: widget.zIndex ?? index, + }; + }); + + await tx.screenWidget.createMany({ + data: mappedWidgets, + }); + } + + this.logger.log(`Screen design ${id} widgets updated: ${widgets.length} widgets`); + } + + // Fetch and return the updated design with all relations + return tx.screenDesign.findUnique({ + where: { id }, + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + }, + }); + }); + + this.logger.log(`Screen design updated: ${updatedDesign?.name}`); + + // Invalidate any existing capture file (it's now stale) + this.invalidateCapture(id); + + // Notify devices that use this screen design to refresh + await this.eventsService.notifyScreenDesignUpdate(id); + + // Transform widgets to restore virtual template IDs for custom widgets + return updatedDesign ? this.transformScreenDesignForResponse(updatedDesign) : null; + } + + /** + * Delete a screen design + */ + async deleteScreenDesign(id: number) { + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id }, + include: { + _count: { + select: { + assignments: true, + }, + }, + }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + // Clean up capture file before deleting from database + this.invalidateCapture(id); + + // Clean up drawing file if exists + const drawingPath = path.join(process.cwd(), 'uploads', 'drawings', `drawing_${id}.png`); + try { + if (fs.existsSync(drawingPath)) { + fs.unlinkSync(drawingPath); + this.logger.log(`Deleted drawing for screen design ${id}`); + } + } catch (error) { + this.logger.warn(`Failed to delete drawing for screen design ${id}: ${error.message}`); + } + + await this.prisma.screenDesign.delete({ + where: { id }, + }); + + this.logger.log(`Screen design deleted: ${screenDesign.name}`); + + return { message: 'Screen design deleted successfully' }; + } + + /** + * Refresh all devices using a screen design + * Triggers immediate refresh for all devices that have this design in their playlist + */ + async refreshDevicesUsingDesign(id: number) { + // Verify screen design exists + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + // Trigger refresh for all devices using this design + const deviceCount = await this.eventsService.notifyScreenDesignUpdate(id); + + this.logger.log(`Refresh triggered for ${deviceCount} devices using screen design ${id}`); + + return { + message: `Refresh triggered for ${deviceCount} device(s)`, + deviceCount, + }; + } + + /** + * Update the capture timestamp for a screen design + * This touches the updatedAt field to mark when the last capture was made + */ + async updateCaptureTimestamp(id: number) { + await this.prisma.screenDesign.update({ + where: { id }, + data: { + // Touching updatedAt by re-setting the name (Prisma auto-updates updatedAt) + updatedAt: new Date(), + }, + }); + } + + /** + * Invalidate (delete) the capture file for a screen design. + * Called when design is updated to prevent serving stale captures. + */ + invalidateCapture(id: number) { + const capturePath = path.join(process.cwd(), 'uploads', 'captures', `capture_${id}.png`); + try { + if (fs.existsSync(capturePath)) { + fs.unlinkSync(capturePath); + this.logger.log(`Invalidated capture for screen design ${id}`); + } + } catch (error) { + this.logger.warn(`Failed to invalidate capture for screen design ${id}: ${error.message}`); + } + } + + /** + * Get a single screen design with widgets + */ + async getScreenDesign(id: number) { + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id }, + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + assignments: { + include: { + device: { + select: { + id: true, + name: true, + macAddress: true, + }, + }, + }, + }, + }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + // Transform widgets to restore virtual template IDs for custom widgets + return this.transformScreenDesignForResponse(screenDesign); + } + + /** + * Get all screen designs with pagination + */ + async getAllScreenDesigns(page = 1, limit = 20) { + const skip = (page - 1) * limit; + + const [screenDesigns, total] = await Promise.all([ + this.prisma.screenDesign.findMany({ + include: { + widgets: { + include: { + template: { + select: { + id: true, + name: true, + label: true, + category: true, + }, + }, + }, + orderBy: { + zIndex: 'asc', + }, + }, + _count: { + select: { + assignments: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + skip, + take: limit, + }), + this.prisma.screenDesign.count(), + ]); + + // Transform each design to restore virtual template IDs for custom widgets + const transformedDesigns = screenDesigns.map(design => + this.transformScreenDesignForResponse(design) + ); + + return wrapPaginatedResponse(transformedDesigns, total, page, limit); + } + + /** + * Add a widget to a screen design + */ + async addWidget(screenDesignId: number, dto: CreateWidgetDto) { + // Verify screen design exists + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id: screenDesignId }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + // Verify template exists + const template = await this.prisma.widgetTemplate.findUnique({ + where: { id: dto.templateId }, + }); + + if (!template) { + throw new BadRequestException('Widget template not found'); + } + + // Check minimum dimensions + const width = dto.width ?? 200; + const height = dto.height ?? 100; + + if (width < template.minWidth || height < template.minHeight) { + throw new BadRequestException( + `Widget dimensions must be at least ${template.minWidth}x${template.minHeight}`, + ); + } + + // Merge default config with provided config + const config = { + ...(template.defaultConfig as object), + ...(dto.config ?? {}), + }; + + const widget = await this.prisma.screenWidget.create({ + data: { + screenDesignId, + templateId: dto.templateId, + x: dto.x ?? 0, + y: dto.y ?? 0, + width, + height, + rotation: dto.rotation ?? 0, + config, + zIndex: dto.zIndex ?? 0, + }, + include: { + template: true, + }, + }); + + this.logger.log( + `Widget added to screen design ${screenDesignId}: ${template.name}`, + ); + + // Notify devices that use this screen design to refresh + await this.eventsService.notifyScreenDesignUpdate(screenDesignId); + + return widget; + } + + /** + * Update a widget + */ + async updateWidget( + screenDesignId: number, + widgetId: number, + dto: UpdateWidgetDto, + ) { + // Get widget with screen design + const widget = await this.prisma.screenWidget.findUnique({ + where: { id: widgetId }, + include: { + screenDesign: true, + template: true, + }, + }); + + if (!widget) { + throw new NotFoundException('Widget not found'); + } + + if (widget.screenDesignId !== screenDesignId) { + throw new BadRequestException('Widget does not belong to this screen design'); + } + + // Check minimum dimensions if provided + const width = dto.width ?? widget.width; + const height = dto.height ?? widget.height; + + if (width < widget.template.minWidth || height < widget.template.minHeight) { + throw new BadRequestException( + `Widget dimensions must be at least ${widget.template.minWidth}x${widget.template.minHeight}`, + ); + } + + // Merge existing config with updates + const config = dto.config + ? { ...(widget.config as object), ...dto.config } + : undefined; + + const updatedWidget = await this.prisma.screenWidget.update({ + where: { id: widgetId }, + data: { + x: dto.x, + y: dto.y, + width: dto.width, + height: dto.height, + rotation: dto.rotation, + config, + zIndex: dto.zIndex, + }, + include: { + template: true, + }, + }); + + this.logger.log(`Widget ${widgetId} updated`); + + // Notify devices that use this screen design to refresh + await this.eventsService.notifyScreenDesignUpdate(screenDesignId); + + return updatedWidget; + } + + /** + * Remove a widget from a screen design + */ + async removeWidget(screenDesignId: number, widgetId: number) { + const widget = await this.prisma.screenWidget.findUnique({ + where: { id: widgetId }, + include: { + screenDesign: true, + template: true, + }, + }); + + if (!widget) { + throw new NotFoundException('Widget not found'); + } + + if (widget.screenDesignId !== screenDesignId) { + throw new BadRequestException('Widget does not belong to this screen design'); + } + + await this.prisma.screenWidget.delete({ + where: { id: widgetId }, + }); + + this.logger.log(`Widget ${widgetId} removed from screen design ${screenDesignId}`); + + // Notify devices that use this screen design to refresh + await this.eventsService.notifyScreenDesignUpdate(screenDesignId); + + return { message: 'Widget removed successfully' }; + } + + /** + * Assign a screen design to a device + */ + async assignToDevice(screenDesignId: number, dto: AssignDeviceDto) { + // Verify screen design exists + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id: screenDesignId }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + // Verify device exists + const device = await this.prisma.device.findUnique({ + where: { id: dto.deviceId }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Check if assignment already exists + const existingAssignment = await this.prisma.deviceScreenAssignment.findUnique({ + where: { + deviceId_screenDesignId: { + deviceId: dto.deviceId, + screenDesignId, + }, + }, + }); + + if (existingAssignment) { + throw new BadRequestException( + 'This screen design is already assigned to this device', + ); + } + + const assignment = await this.prisma.deviceScreenAssignment.create({ + data: { + deviceId: dto.deviceId, + screenDesignId, + isActive: true, + }, + include: { + device: { + select: { + id: true, + name: true, + macAddress: true, + }, + }, + screenDesign: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + this.logger.log( + `Screen design ${screenDesignId} assigned to device ${dto.deviceId}`, + ); + + return assignment; + } + + /** + * Unassign a screen design from a device + */ + async unassignFromDevice(screenDesignId: number, deviceId: number) { + const assignment = await this.prisma.deviceScreenAssignment.findUnique({ + where: { + deviceId_screenDesignId: { + deviceId, + screenDesignId, + }, + }, + include: { + device: true, + screenDesign: true, + }, + }); + + if (!assignment) { + throw new NotFoundException('Assignment not found'); + } + + await this.prisma.deviceScreenAssignment.delete({ + where: { + deviceId_screenDesignId: { + deviceId, + screenDesignId, + }, + }, + }); + + this.logger.log( + `Screen design ${screenDesignId} unassigned from device ${deviceId}`, + ); + + return { message: 'Screen design unassigned from device successfully' }; + } + + /** + * Get all screen designs assigned to a device + */ + async getDeviceScreenDesigns(deviceId: number) { + // Verify device exists + const device = await this.prisma.device.findUnique({ + where: { id: deviceId }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + const assignments = await this.prisma.deviceScreenAssignment.findMany({ + where: { deviceId }, + include: { + screenDesign: { + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + const screenDesigns = assignments.map((a) => ({ + ...this.transformScreenDesignForResponse(a.screenDesign), + assignmentId: a.id, + isActive: a.isActive, + assignedAt: a.createdAt, + })); + + return wrapListResponse(screenDesigns); + } + + /** + * Get the active screen design for a device (for rendering) + */ + async getActiveScreenDesignForDevice(deviceId: number) { + const assignment = await this.prisma.deviceScreenAssignment.findFirst({ + where: { + deviceId, + isActive: true, + }, + include: { + screenDesign: { + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (!assignment) { + return null; + } + + // Transform widgets to restore virtual template IDs for custom widgets + return this.transformScreenDesignForResponse(assignment.screenDesign); + } + + /** + * Duplicate a screen design + */ + async duplicateScreenDesign(id: number, newName?: string) { + const original = await this.prisma.screenDesign.findUnique({ + where: { id }, + include: { + widgets: true, + }, + }); + + if (!original) { + throw new NotFoundException('Screen design not found'); + } + + const duplicate = await this.prisma.screenDesign.create({ + data: { + name: newName || `${original.name} (Copy)`, + description: original.description, + width: original.width, + height: original.height, + background: original.background, + isTemplate: false, // Copies are never templates by default + widgets: { + create: original.widgets.map((widget) => ({ + templateId: widget.templateId, + x: widget.x, + y: widget.y, + width: widget.width, + height: widget.height, + rotation: widget.rotation, + config: widget.config as object, + zIndex: widget.zIndex, + })), + }, + }, + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + }, + }); + + this.logger.log( + `Screen design duplicated: ${original.name} -> ${duplicate.name}`, + ); + + return this.transformScreenDesignForResponse(duplicate); + } + + /** + * Transform screen design for API response + * Restores virtual template IDs for custom widgets based on customWidgetId in config + * Adds captureUrl if a browser capture file exists + */ + private transformScreenDesignForResponse(screenDesign: any): any { + if (!screenDesign || !screenDesign.widgets) { + return screenDesign; + } + + // Check if capture file exists + const captureFilename = `capture_${screenDesign.id}.png`; + const capturePath = path.join(process.cwd(), 'uploads', 'captures', captureFilename); + const captureExists = fs.existsSync(capturePath); + const captureUrl = captureExists ? `/uploads/captures/${captureFilename}` : null; + + return { + ...screenDesign, + captureUrl, // Include capture URL if exists (for preview with drawings) + widgets: screenDesign.widgets.map((widget: any) => { + const config = widget.config as Record; + const customWidgetId = config?.customWidgetId as number | undefined; + + // If this widget has a customWidgetId, restore the virtual template ID + if (customWidgetId !== undefined && customWidgetId !== null) { + return { + ...widget, + templateId: CUSTOM_WIDGET_TEMPLATE_OFFSET + customWidgetId, + }; + } + + return widget; + }), + }; + } +} diff --git a/backend/src/screen-designer/services/screen-renderer.service.ts b/backend/src/screen-designer/services/screen-renderer.service.ts new file mode 100644 index 0000000..2c7aa2d --- /dev/null +++ b/backend/src/screen-designer/services/screen-renderer.service.ts @@ -0,0 +1,3158 @@ +import { + Injectable, + NotFoundException, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CustomWidgetsService } from '../../custom-widgets/custom-widgets.service'; +import { SettingsService } from '../../settings/settings.service'; +import * as sharpModule from 'sharp'; +import type { Sharp, FitEnum } from 'sharp'; +// Handle both ESM and CJS imports for Bun compatibility +const sharp = (sharpModule as any).default || sharpModule; +import puppeteer, { Browser } from 'puppeteer'; +import QRCode from 'qrcode'; +import { validateUrlSafety } from '../../common/utils/url-safety'; +import type { ScreenDesign, ScreenWidget, WidgetTemplate } from '@prisma/client'; + +type WidgetWithTemplate = ScreenWidget & { template: WidgetTemplate }; +type ScreenDesignWithWidgets = ScreenDesign & { widgets: WidgetWithTemplate[] }; + +/** + * Device context passed during rendering + * Contains real-time device data for battery/wifi/info widgets + */ +export interface DeviceContext { + battery?: number; // Battery percentage (0-100) + wifi?: number; // WiFi RSSI in dBm (e.g., -51) + deviceName?: string; // Device name/label + firmwareVersion?: string; // Firmware version + macAddress?: string; // MAC address +} + +/** + * Render mode for screen designs + * - 'device': Full e-ink processing with inversion (for actual device display) + * - 'preview': No e-ink processing (RGB preview for admin UI) + * - 'einkPreview': Full e-ink processing WITHOUT inversion (pixel-perfect preview on RGB display) + */ +export type RenderMode = 'device' | 'preview' | 'einkPreview'; + +/** + * Screen Renderer Service + * Renders screen designs with widgets to PNG images for e-ink devices + * Uses Puppeteer for 1:1 HTML/CSS rendering matching the frontend + */ +@Injectable() +export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { + private readonly logger = new Logger(ScreenRendererService.name); + private browser: Browser | null = null; + private fontsBase64: Record = {}; + private fontStyleTag: string = ''; + + // GitHub API cache to reduce rate limit usage (5 minute TTL) + private githubCache: Map = new Map(); + private readonly GITHUB_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + private readonly GITHUB_CACHE_MAX_SIZE = 100; + + constructor( + private prisma: PrismaService, + private customWidgetsService: CustomWidgetsService, + private configService: ConfigService, + private settingsService: SettingsService, + ) {} + + /** + * Resolve an /uploads/ path safely, preventing path traversal via ../ + */ + private resolveUploadPath(url: string): string { + const uploadsRoot = path.resolve(process.cwd(), 'uploads'); + const resolved = path.resolve(process.cwd(), url.slice(1)); // remove leading / + if (!resolved.startsWith(uploadsRoot + path.sep) && resolved !== uploadsRoot) { + throw new Error('Path traversal detected'); + } + return resolved; + } + + /** + * Load fonts at module initialization + */ + async onModuleInit() { + try { + const fontsDir = path.join(process.cwd(), 'assets', 'fonts'); + + if (!fs.existsSync(fontsDir)) { + this.logger.warn(`Fonts directory not found: ${fontsDir}`); + return; + } + + const fontFiles = [ + { name: 'Inter-Regular', weight: 400, family: 'Inter' }, + { name: 'Inter-Medium', weight: 500, family: 'Inter' }, + { name: 'Inter-SemiBold', weight: 600, family: 'Inter' }, + { name: 'Inter-Bold', weight: 700, family: 'Inter' }, + { name: 'RobotoMono-Regular', weight: 400, family: 'Roboto Mono' }, + { name: 'RobotoMono-Medium', weight: 500, family: 'Roboto Mono' }, + { name: 'RobotoMono-Bold', weight: 700, family: 'Roboto Mono' }, + { name: 'Merriweather-Regular', weight: 400, family: 'Merriweather' }, + { name: 'Merriweather-Bold', weight: 700, family: 'Merriweather' }, + ]; + + const fontFaces: string[] = []; + + for (const font of fontFiles) { + const fontPath = path.join(fontsDir, `${font.name}.woff2`); + if (fs.existsSync(fontPath)) { + const fontData = fs.readFileSync(fontPath); + this.fontsBase64[font.name] = fontData.toString('base64'); + + fontFaces.push(` + @font-face { + font-family: '${font.family}'; + font-style: normal; + font-weight: ${font.weight}; + font-display: block; + src: url(data:font/woff2;base64,${this.fontsBase64[font.name]}) format('woff2'); + } + `); + } else { + this.logger.warn(`Font file not found: ${fontPath}`); + } + } + + this.fontStyleTag = ``; + this.logger.log(`Loaded ${Object.keys(this.fontsBase64).length} fonts for rendering`); + } catch (error) { + this.logger.error('Failed to load fonts:', error); + } + } + + /** + * Map generic CSS font families to specific loaded font names + * This ensures the loaded woff2 fonts are actually used + */ + private mapFontFamily(fontFamily: string): string { + switch (fontFamily) { + case 'sans-serif': + return "'Inter', sans-serif"; + case 'monospace': + return "'Roboto Mono', monospace"; + case 'serif': + return "'Merriweather', serif"; + default: + // If already a specific font or unknown, return with fallback + return fontFamily.includes(',') ? fontFamily : `${fontFamily}, sans-serif`; + } + } + + /** + * Get or create Puppeteer browser instance + * Handles browser reconnection if the browser crashes or disconnects + */ + private async getBrowser(): Promise { + // Check if browser exists and is still connected + if (this.browser) { + try { + // Test if browser is still responsive by checking if it's connected + if (!this.browser.connected) { + this.logger.warn('Puppeteer browser disconnected, reinitializing...'); + this.browser = null; + } + } catch { + // Browser is in an invalid state, reset it + this.logger.warn('Puppeteer browser in invalid state, reinitializing...'); + this.browser = null; + } + } + + if (!this.browser) { + this.logger.debug('Launching new Puppeteer browser instance'); + this.browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--font-render-hinting=none', + '--disable-font-subpixel-positioning', + '--force-color-profile=srgb', + // Network hardening — reduce Chrome's attack surface + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--disable-translate', + '--metrics-recording-only', + '--no-first-run', + ], + }); + + // Set up disconnect handler to reset browser reference + this.browser.on('disconnected', () => { + this.logger.warn('Puppeteer browser disconnected unexpectedly'); + this.browser = null; + }); + } + return this.browser; + } + + /** + * Cleanup browser on module destroy + */ + async onModuleDestroy() { + if (this.browser) { + await this.browser.close(); + this.browser = null; + } + } + + /** + * Render a screen design to PNG buffer + * @param screenDesignId - ID of the screen design to render + * @param deviceContext - Optional device data for battery/wifi/info widgets + * @param mode - Render mode: 'device' (full e-ink), 'preview' (no processing), 'einkPreview' (e-ink without inversion) + */ + async renderScreenDesign(screenDesignId: number, deviceContext?: DeviceContext, mode: RenderMode | boolean = 'device'): Promise { + // Support legacy boolean parameter for backwards compatibility + const renderMode: RenderMode = typeof mode === 'boolean' ? (mode ? 'preview' : 'device') : mode; + + const screenDesign = await this.prisma.screenDesign.findUnique({ + where: { id: screenDesignId }, + include: { + widgets: { + include: { + template: true, + }, + orderBy: { + zIndex: 'asc', + }, + }, + }, + }); + + if (!screenDesign) { + throw new NotFoundException('Screen design not found'); + } + + return this.renderDesign(screenDesign as ScreenDesignWithWidgets, deviceContext, renderMode); + } + + /** + * Render a preview thumbnail of the screen design + */ + async renderPreview(screenDesignId: number): Promise { + const fullImage = await this.renderScreenDesign(screenDesignId, undefined, true); + + // Create a 200px wide thumbnail + const thumbnail = await sharp(fullImage) + .resize(200, null, { + fit: 'inside', + withoutEnlargement: true, + }) + .png() + .toBuffer(); + + return thumbnail; + } + + /** + * Internal render method - uses HTML/CSS + Puppeteer for pixel-perfect rendering + * @param mode - Render mode: 'device' (full e-ink), 'preview' (no processing), 'einkPreview' (e-ink without inversion) + */ + private async renderDesign(screenDesign: ScreenDesignWithWidgets, deviceContext?: DeviceContext, mode: RenderMode = 'device'): Promise { + const { width, height } = screenDesign; + + this.logger.debug( + `Rendering screen design: ${screenDesign.name} (${width}x${height}, mode=${mode})`, + ); + + // Use HTML-based rendering for pixel-perfect output matching frontend + let renderBuffer = await this.renderDesignAsHtml(screenDesign, deviceContext); + + // Check if a separate drawing file exists and composite it on top + // This allows screens with dynamic widgets (clock, etc.) to also display drawings + const drawingPath = path.join(process.cwd(), 'uploads', 'drawings', `drawing_${screenDesign.id}.png`); + if (fs.existsSync(drawingPath)) { + this.logger.debug(`Compositing drawing overlay: ${drawingPath}`); + const drawingBuffer = await fs.promises.readFile(drawingPath); + renderBuffer = await sharp(renderBuffer) + .composite([{ input: drawingBuffer, top: 0, left: 0 }]) + .png() + .toBuffer(); + } + + // For preview mode, return the composited screenshot directly + if (mode === 'preview') { + return renderBuffer; + } + + // Apply e-ink processing for 'device' and 'einkPreview' modes + // 'device' mode inverts colors for TRMNL e-ink display + // 'einkPreview' mode applies dithering but no inversion (for admin preview) + const shouldNegate = mode === 'device'; + + // Create Sharp instance from the composited screenshot for e-ink processing + const canvas = sharp(renderBuffer); + + return this.applyEinkProcessing(canvas, width, height, shouldNegate); + } + + /** + * Apply e-ink processing to a sharp canvas + * - Grayscale conversion + * - Contrast normalization + * - Floyd-Steinberg dithering + * - Optional color inversion (for device display) + * - 1-bit PNG output + * + * @param canvas - Sharp canvas with composited widgets + * @param width - Original canvas width + * @param height - Original canvas height + * @param negate - If true, invert colors (required for TRMNL e-ink devices) + */ + private async applyEinkProcessing( + canvas: Sharp, + width: number, + height: number, + negate: boolean, + ): Promise { + const MAX_SIZE = 90000; // Max 90KB for TRMNL devices + const threshold = 140; // Higher threshold favors white + + // First get grayscale raw pixels for Floyd-Steinberg dithering + const grayBuffer = await canvas + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + const { data, info } = grayBuffer; + + // Apply Floyd-Steinberg dithering + const ditheredBuffer = this.applyFloydSteinbergDithering(data, info.width, info.height, threshold); + + // Create 1-bit PNG + let sharpInstance = sharp(ditheredBuffer, { + raw: { + width: info.width, + height: info.height, + channels: 1, + }, + }); + + // Apply negate for device mode (TRMNL expects inverted colors) + if (negate) { + sharpInstance = sharpInstance.negate(); + } + + let buffer = await sharpInstance + .png({ + compressionLevel: 9, + palette: true, + colours: 2, + }) + .toBuffer(); + + // If still too large, scale down and re-dither + let scale = 1.0; + let attempts = 0; + while (buffer.length > MAX_SIZE && attempts < 10) { + scale *= 0.9; + attempts++; + + const newWidth = Math.round(width * scale); + const newHeight = Math.round(height * scale); + + this.logger.debug( + `Screen too large (${buffer.length} bytes), scaling to ${newWidth}x${newHeight}`, + ); + + // Re-render at smaller size + const scaledGray = await canvas + .resize(newWidth, newHeight) + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + const scaledDithered = this.applyFloydSteinbergDithering( + scaledGray.data, + scaledGray.info.width, + scaledGray.info.height, + threshold, + ); + + let scaledSharp = sharp(scaledDithered, { + raw: { + width: scaledGray.info.width, + height: scaledGray.info.height, + channels: 1, + }, + }); + + if (negate) { + scaledSharp = scaledSharp.negate(); + } + + buffer = await scaledSharp + .png({ + compressionLevel: 9, + palette: true, + colours: 2, + }) + .toBuffer(); + } + + this.logger.debug( + `E-ink processing complete: ${buffer.length} bytes, 1-bit, negate=${negate}`, + ); + + return buffer; + } + + /** + * Apply Floyd-Steinberg dithering algorithm + * Converts grayscale image data to 1-bit with error diffusion + */ + private applyFloydSteinbergDithering( + data: Buffer, + width: number, + height: number, + threshold: number, + ): Buffer { + const pixels = new Float32Array(data.length); + + // Pre-dithering contrast enhancement + for (let i = 0; i < data.length; i++) { + const val = data[i]; + if (val > 200) pixels[i] = 255; // Near-white becomes pure white + else if (val < 55) pixels[i] = 0; // Near-black becomes pure black + else pixels[i] = val; + } + + // Floyd-Steinberg dithering + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const oldPixel = pixels[idx]; + const newPixel = oldPixel < threshold ? 0 : 255; + pixels[idx] = newPixel; + const error = oldPixel - newPixel; + + // Error diffusion: 7/16, 3/16, 5/16, 1/16 + if (x + 1 < width) { + pixels[idx + 1] += (error * 7) / 16; + } + if (x - 1 >= 0 && y + 1 < height) { + pixels[(y + 1) * width + (x - 1)] += (error * 3) / 16; + } + if (y + 1 < height) { + pixels[(y + 1) * width + x] += (error * 5) / 16; + } + if (x + 1 < width && y + 1 < height) { + pixels[(y + 1) * width + (x + 1)] += (error * 1) / 16; + } + } + } + + // Convert back to buffer + const output = Buffer.alloc(data.length); + for (let i = 0; i < pixels.length; i++) { + output[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + + return output; + } + + /** + * Render a single widget to a buffer + */ + private async renderWidget(widget: WidgetWithTemplate, deviceContext?: DeviceContext): Promise { + const { template, width, height, config } = widget; + const widgetConfig = config as Record; + + switch (template.name) { + case 'clock': + return this.renderClockWidget(width, height, widgetConfig); + + case 'date': + return this.renderDateWidget(width, height, widgetConfig); + + case 'text': + return this.renderTextWidget(width, height, widgetConfig); + + case 'weather': + return this.renderWeatherWidget(width, height, widgetConfig); + + case 'qrcode': + return this.renderQRCodeWidget(width, height, widgetConfig); + + case 'battery': + return this.renderBatteryWidget(width, height, widgetConfig, deviceContext); + + case 'countdown': + return this.renderCountdownWidget(width, height, widgetConfig); + + case 'divider': + return this.renderDividerWidget(width, height, widgetConfig); + + case 'rectangle': + return this.renderRectangleWidget(width, height, widgetConfig); + + case 'wifi': + return this.renderWifiWidget(width, height, widgetConfig, deviceContext); + + case 'deviceinfo': + return this.renderDeviceInfoWidget(width, height, widgetConfig, deviceContext); + + case 'image': + return this.renderImageWidget(width, height, widgetConfig); + + case 'daysuntil': + return this.renderDaysUntilWidget(width, height, widgetConfig); + + case 'github': + return this.renderGitHubWidget(width, height, widgetConfig); + + case 'custom-widget-base': + return this.renderCustomWidget(width, height, widgetConfig); + + default: + this.logger.warn(`Unknown widget type: ${template.name}`); + return this.renderPlaceholderWidget(width, height, template.label); + } + } + + /** + * Render clock widget + */ + private async renderClockWidget( + width: number, + height: number, + config: Record, + ): Promise { + const now = new Date(); + const format = config.format || '24h'; + const showSeconds = config.showSeconds || false; + const fontSize = config.fontSize || 48; + + // Handle timezone: + // - 'local' or empty: Use configured DEFAULT_TIMEZONE (falls back to UTC) + // - Otherwise use the specified IANA timezone like 'Europe/Warsaw', 'America/New_York' + // Note: Docker containers default to UTC, so this is configurable via env var + const timezone = config.timezone || 'local'; + const defaultTimezone = this.configService.get('defaultTimezone', 'UTC'); + const effectiveTimezone = (timezone === 'local' || timezone === '') + ? defaultTimezone + : timezone; + + // Format time based on timezone + const options: Intl.DateTimeFormatOptions = { + timeZone: effectiveTimezone, + hour: '2-digit', + minute: '2-digit', + second: showSeconds ? '2-digit' : undefined, + hour12: format === '12h', + }; + + const timeStr = now.toLocaleTimeString('en-US', options); + + this.logger.debug(`Rendering clock: timezone=${effectiveTimezone}, time=${timeStr}`); + + return this.renderTextToBuffer(timeStr, width, height, { + fontSize, + fontFamily: config.fontFamily || 'monospace', + color: this.sanitizeColor(config.color || '#000000'), + textAlign: config.textAlign || 'left', + noWrap: true, + }); + } + + /** + * Render date widget + * Supports configurable display of: weekday, day, month, year + */ + private async renderDateWidget( + width: number, + height: number, + config: Record, + ): Promise { + const now = new Date(); + const fontSize = config.fontSize || 24; + const locale = config.locale || 'en-US'; + + // Configurable date parts (all default to true except weekday) + const showWeekday = config.showWeekday ?? config.showDayOfWeek ?? false; + const showDay = config.showDay ?? true; + const showMonth = config.showMonth ?? true; + const showYear = config.showYear ?? true; + + // Handle timezone: + // - 'local' or empty: Use configured DEFAULT_TIMEZONE (falls back to UTC) + // - Otherwise use the specified IANA timezone like 'Europe/Warsaw', 'America/New_York' + // Note: Docker containers default to UTC, so this is configurable via env var + const timezone = config.timezone || 'local'; + const defaultTimezone = this.configService.get('defaultTimezone', 'UTC'); + const effectiveTimezone = (timezone === 'local' || timezone === '') + ? defaultTimezone + : timezone; + + // Build format options based on what should be shown + const options: Intl.DateTimeFormatOptions = { + timeZone: effectiveTimezone, + }; + + if (showWeekday) { + options.weekday = 'long'; + } + if (showDay) { + options.day = 'numeric'; + } + if (showMonth) { + options.month = 'long'; + } + if (showYear) { + options.year = 'numeric'; + } + + // Fallback if nothing is selected + if (!showWeekday && !showDay && !showMonth && !showYear) { + options.day = 'numeric'; + options.month = 'long'; + options.year = 'numeric'; + } + + const dateStr = now.toLocaleDateString(locale, options); + + return this.renderTextToBuffer(dateStr, width, height, { + fontSize, + fontFamily: config.fontFamily || 'sans-serif', + color: this.sanitizeColor(config.color || '#000000'), + textAlign: config.textAlign || 'center', + noWrap: true, + }); + } + + /** + * Render text widget + */ + private async renderTextWidget( + width: number, + height: number, + config: Record, + ): Promise { + const text = config.text || 'Text'; + const fontSize = config.fontSize || 24; + + return this.renderTextToBuffer(text, width, height, { + fontSize, + fontFamily: config.fontFamily || 'sans-serif', + fontWeight: config.fontWeight || 'normal', + color: this.sanitizeColor(config.color || '#000000'), + textAlign: config.textAlign || 'left', + }); + } + + /** + * Weather code to condition text mapping (WMO codes) + * https://open-meteo.com/en/docs + */ + private getWeatherCondition(code: number): { text: string; icon: string } { + const conditions: Record = { + 0: { text: 'Clear', icon: 'sun' }, + 1: { text: 'Mostly Clear', icon: 'sun' }, + 2: { text: 'Partly Cloudy', icon: 'cloud-sun' }, + 3: { text: 'Cloudy', icon: 'cloud' }, + 45: { text: 'Foggy', icon: 'fog' }, + 48: { text: 'Icy Fog', icon: 'fog' }, + 51: { text: 'Light Drizzle', icon: 'drizzle' }, + 53: { text: 'Drizzle', icon: 'drizzle' }, + 55: { text: 'Heavy Drizzle', icon: 'drizzle' }, + 56: { text: 'Freezing Drizzle', icon: 'drizzle' }, + 57: { text: 'Heavy Freezing Drizzle', icon: 'drizzle' }, + 61: { text: 'Light Rain', icon: 'rain' }, + 63: { text: 'Rain', icon: 'rain' }, + 65: { text: 'Heavy Rain', icon: 'rain' }, + 66: { text: 'Freezing Rain', icon: 'rain' }, + 67: { text: 'Heavy Freezing Rain', icon: 'rain' }, + 71: { text: 'Light Snow', icon: 'snow' }, + 73: { text: 'Snow', icon: 'snow' }, + 75: { text: 'Heavy Snow', icon: 'snow' }, + 77: { text: 'Snow Grains', icon: 'snow' }, + 80: { text: 'Light Showers', icon: 'rain' }, + 81: { text: 'Showers', icon: 'rain' }, + 82: { text: 'Heavy Showers', icon: 'rain' }, + 85: { text: 'Snow Showers', icon: 'snow' }, + 86: { text: 'Heavy Snow Showers', icon: 'snow' }, + 95: { text: 'Thunderstorm', icon: 'thunder' }, + 96: { text: 'Thunderstorm + Hail', icon: 'thunder' }, + 99: { text: 'Heavy Thunderstorm', icon: 'thunder' }, + }; + return conditions[code] || { text: 'Unknown', icon: 'cloud' }; + } + + /** + * Get weather icon SVG path + */ + private getWeatherIconSvg(icon: string, size: number, color: string): string { + const cx = size / 2; + const cy = size / 2; + const r = size * 0.3; + + switch (icon) { + case 'sun': + // Sun with rays + return ` + + ${[0, 45, 90, 135, 180, 225, 270, 315].map(angle => { + const rad = (angle * Math.PI) / 180; + const x1 = cx + Math.cos(rad) * (r + 4); + const y1 = cy + Math.sin(rad) * (r + 4); + const x2 = cx + Math.cos(rad) * (r + 10); + const y2 = cy + Math.sin(rad) * (r + 10); + return ``; + }).join('')} + `; + case 'cloud': + return ` + + + + `; + case 'cloud-sun': + return ` + + ${[0, 60, 120, 180, 240, 300].map(angle => { + const rad = (angle * Math.PI) / 180; + const x1 = cx + 10 + Math.cos(rad) * (r * 0.5 + 3); + const y1 = cy - 8 + Math.sin(rad) * (r * 0.5 + 3); + const x2 = cx + 10 + Math.cos(rad) * (r * 0.5 + 7); + const y2 = cy - 8 + Math.sin(rad) * (r * 0.5 + 7); + return ``; + }).join('')} + + + + `; + case 'rain': + return ` + + + + + + + `; + case 'drizzle': + return ` + + + + + + + `; + case 'snow': + return ` + + + + * + * + * + `; + case 'thunder': + return ` + + + + + `; + case 'fog': + return ` + + + + `; + default: + return ``; + } + } + + /** + * Fetch weather data from Open-Meteo API + */ + private async fetchWeatherData( + latitude: number, + longitude: number, + forecastDay: number, + forecastTime: string, + ): Promise<{ + temperature: number; + weatherCode: number; + humidity: number; + windSpeed: number; + dayName: string; + } | null> { + try { + // Build API URL with required parameters + const url = new URL('https://api.open-meteo.com/v1/forecast'); + url.searchParams.set('latitude', latitude.toString()); + url.searchParams.set('longitude', longitude.toString()); + url.searchParams.set('current', 'temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m'); + url.searchParams.set('hourly', 'temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m'); + url.searchParams.set('forecast_days', Math.max(forecastDay + 1, 1).toString()); + url.searchParams.set('timezone', 'auto'); + + this.logger.debug(`Fetching weather from: ${url.toString()}`); + + // Add timeout to prevent hanging on slow weather API + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(url.toString(), { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Weather API error: ${response.status}`); + } + + const data = await response.json(); + + // Determine which data to use based on forecastDay and forecastTime + const now = new Date(); + let targetDate = new Date(now); + targetDate.setDate(targetDate.getDate() + forecastDay); + + // Get day name + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + let dayName = dayNames[targetDate.getDay()]; + if (forecastDay === 0) dayName = 'Today'; + else if (forecastDay === 1) dayName = 'Tomorrow'; + + // If current weather (day 0, time current), use current data + if (forecastDay === 0 && forecastTime === 'current') { + return { + temperature: Math.round(data.current.temperature_2m), + weatherCode: data.current.weather_code, + humidity: data.current.relative_humidity_2m, + windSpeed: Math.round(data.current.wind_speed_10m), + dayName, + }; + } + + // Otherwise, find the appropriate hourly data + const hourMap: Record = { + 'current': now.getHours(), + 'morning': 8, + 'noon': 12, + 'afternoon': 15, + 'evening': 19, + 'night': 22, + }; + + const targetHour = hourMap[forecastTime] ?? 12; + + // Build target datetime string (YYYY-MM-DDTHH:00) + const year = targetDate.getFullYear(); + const month = String(targetDate.getMonth() + 1).padStart(2, '0'); + const day = String(targetDate.getDate()).padStart(2, '0'); + const hour = String(targetHour).padStart(2, '0'); + const targetTimeStr = `${year}-${month}-${day}T${hour}:00`; + + // Find the index in hourly data + const hourlyTimes = data.hourly?.time || []; + const index = hourlyTimes.findIndex((t: string) => t === targetTimeStr); + + if (index >= 0 && data.hourly) { + return { + temperature: Math.round(data.hourly.temperature_2m[index]), + weatherCode: data.hourly.weather_code[index], + humidity: data.hourly.relative_humidity_2m[index], + windSpeed: Math.round(data.hourly.wind_speed_10m[index]), + dayName, + }; + } + + // Fallback to current if hourly not found + return { + temperature: Math.round(data.current.temperature_2m), + weatherCode: data.current.weather_code, + humidity: data.current.relative_humidity_2m, + windSpeed: Math.round(data.current.wind_speed_10m), + dayName, + }; + } catch (error) { + this.logger.warn(`Failed to fetch weather: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + /** + * Render weather widget with real data from Open-Meteo API + */ + private async renderWeatherWidget( + width: number, + height: number, + config: Record, + ): Promise { + const location = config.location || 'Unknown'; + const latitude = config.latitude || 52.2297; + const longitude = config.longitude || 21.0122; + const units = config.units || 'metric'; + const forecastDay = config.forecastDay || 0; + const forecastTime = config.forecastTime || 'current'; + const fontSize = config.fontSize || 32; + const showIcon = config.showIcon !== false; + const showTemperature = config.showTemperature !== false; + const showCondition = config.showCondition !== false; + const showLocation = config.showLocation !== false; + const showHumidity = config.showHumidity || false; + const showWind = config.showWind || false; + const showDayName = config.showDayName || false; + const color = this.sanitizeColor(config.color || '#000000'); + + // Fetch real weather data + const weatherData = await this.fetchWeatherData(latitude, longitude, forecastDay, forecastTime); + + if (!weatherData) { + // Fallback to placeholder if API fails + return this.renderTextToBuffer(`${location}\nWeather unavailable`, width, height, { + fontSize: fontSize * 0.7, + fontFamily: 'sans-serif', + color: '#000000', + textAlign: 'center', + }); + } + + const { temperature, weatherCode, humidity, windSpeed, dayName } = weatherData; + const condition = this.getWeatherCondition(weatherCode); + + // Convert temperature if imperial + const displayTemp = units === 'imperial' + ? Math.round((temperature * 9/5) + 32) + : temperature; + const tempUnit = units === 'imperial' ? '°F' : '°C'; + + // Build SVG content + const iconSize = Math.min(width * 0.3, height * 0.5, 60); + const tempFontSize = fontSize; + const smallFontSize = Math.max(12, fontSize * 0.5); + + let svgContent = ''; + let currentY = 10; + + // Day name (if forecast) + if (showDayName && forecastDay > 0) { + svgContent += ` + + ${dayName} + + `; + currentY += smallFontSize + 5; + } + + // Layout: icon on left, temp and condition on right + const iconX = showIcon ? 10 : 0; + const textX = showIcon ? iconSize + 20 : width / 2; + const textAnchor = showIcon ? 'start' : 'middle'; + + // Weather icon + if (showIcon) { + const iconY = currentY + 5; + svgContent += ` + + ${this.getWeatherIconSvg(condition.icon, iconSize, color)} + + `; + } + + // Temperature + if (showTemperature) { + const tempY = currentY + tempFontSize + 5; + svgContent += ` + + ${displayTemp}${tempUnit} + + `; + currentY = tempY + 5; + } + + // Condition text + if (showCondition) { + currentY += smallFontSize + 3; + svgContent += ` + + ${condition.text} + + `; + } + + // Humidity + if (showHumidity) { + currentY += smallFontSize + 3; + svgContent += ` + + Humidity: ${humidity}% + + `; + } + + // Wind speed + if (showWind) { + currentY += smallFontSize + 3; + const windUnit = units === 'imperial' ? 'mph' : 'km/h'; + const displayWind = units === 'imperial' ? Math.round(windSpeed * 0.621) : windSpeed; + svgContent += ` + + Wind: ${displayWind} ${windUnit} + + `; + } + + // Location name at bottom + if (showLocation) { + currentY = height - 5; + svgContent += ` + + ${this.escapeXml(location)} + + `; + } + + const svg = ` + + ${svgContent} + + `; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } + + /** + * Render QR code widget + * Generates QR code locally using qrcode library (no external API) + */ + private async renderQRCodeWidget( + width: number, + height: number, + config: Record, + ): Promise { + const content = config.content || 'https://example.com'; + const size = Math.min(width, height); + + try { + // Generate QR code locally + const qrBuffer = await QRCode.toBuffer(content, { + width: size, + margin: 1, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, + }); + + // Resize to exact widget dimensions with transparent background + const resizedBuffer = await sharp(qrBuffer) + .resize(width, height, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + + return resizedBuffer; + } catch (error) { + this.logger.warn(`Failed to generate QR code: ${error instanceof Error ? error.message : String(error)}`); + // Fallback to placeholder on error + return this.renderPlaceholderWidget(width, height, 'QR Code'); + } + } + + /** + * Render battery widget with icon + */ + private async renderBatteryWidget( + width: number, + height: number, + config: Record, + deviceContext?: DeviceContext, + ): Promise { + const showPercentage = config.showPercentage ?? true; + const showIcon = config.showIcon ?? true; + const fontSize = config.fontSize || 18; + const color = this.sanitizeColor(config.color || '#000000'); + + // Use real battery data from device context, or show placeholder + const battery = deviceContext?.battery; + const percentage = battery !== undefined ? Math.round(battery) : 85; // Default 85 for preview + const percentageText = battery !== undefined ? `${percentage}%` : '--%'; + + // Calculate icon size based on font size + const iconSize = Math.max(24, fontSize * 1.2); + const iconWidth = iconSize * 1.2; // Battery icon is wider + const iconHeight = iconSize * 0.7; + + // Calculate fill width based on battery percentage + const fillPercent = typeof percentage === 'number' ? percentage : 0; + const fillWidth = Math.max(0, (fillPercent / 100) * (iconWidth - 8)); + + // Calculate layout + const gap = 8; + let totalContentWidth = 0; + if (showIcon) totalContentWidth += iconWidth + 6; // Icon + terminal + if (showIcon && showPercentage) totalContentWidth += gap; + if (showPercentage) totalContentWidth += percentageText.length * fontSize * 0.6; + + const startX = (width - totalContentWidth) / 2; + const centerY = height / 2; + + // Build SVG elements + let svgContent = ''; + let currentX = startX; + + // Battery icon (outline + fill + terminal) + if (showIcon) { + const iconY = centerY - iconHeight / 2; + + // Battery body outline + svgContent += ` + + `; + + // Battery fill (based on percentage) + if (fillWidth > 0) { + svgContent += ` + + `; + } + + // Battery terminal (the small bump on the right) + svgContent += ` + + `; + + currentX += iconWidth + 6 + gap; + } + + // Percentage text + if (showPercentage) { + const textY = centerY + fontSize * 0.35; + svgContent += ` + + ${percentageText} + + `; + } + + const svg = ` + + ${svgContent} + + `; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } + + /** + * Render countdown widget + */ + private async renderCountdownWidget( + width: number, + height: number, + config: Record, + ): Promise { + const targetDate = new Date(config.targetDate || new Date()); + const label = config.label || 'Countdown'; + const fontSize = config.fontSize || 32; + + const now = new Date(); + const diff = targetDate.getTime() - now.getTime(); + + let countdownText = ''; + if (diff > 0) { + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + const parts: string[] = []; + if (config.showDays !== false && days > 0) parts.push(`${days}d`); + if (config.showHours !== false) parts.push(`${hours}h`); + if (config.showMinutes !== false) parts.push(`${minutes}m`); + + countdownText = parts.join(' '); + } else { + countdownText = 'Expired'; + } + + const text = `${label}\n${countdownText}`; + + return this.renderTextToBuffer(text, width, height, { + fontSize, + fontFamily: config.fontFamily || 'monospace', + color: this.sanitizeColor(config.color || '#000000'), + textAlign: 'left', + noWrap: true, + }); + } + + /** + * Render divider widget + * Creates a line (horizontal or vertical) centered within the widget area + */ + private async renderDividerWidget( + width: number, + height: number, + config: Record, + ): Promise { + const orientation = config.orientation || 'horizontal'; + const color = this.sanitizeColor(config.color || '#000000'); + const thickness = config.thickness || 2; + const style = config.style || 'solid'; + + // Build SVG with line centered in widget area + let lineElement: string; + + if (orientation === 'horizontal') { + const y = Math.floor(height / 2); + const strokeDasharray = style === 'dashed' ? `${thickness * 3},${thickness * 2}` : + style === 'dotted' ? `${thickness},${thickness}` : 'none'; + lineElement = ``; + } else { + const x = Math.floor(width / 2); + const strokeDasharray = style === 'dashed' ? `${thickness * 3},${thickness * 2}` : + style === 'dotted' ? `${thickness},${thickness}` : 'none'; + lineElement = ``; + } + + const svg = ` + + + ${lineElement} + + `; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } + + /** + * Render rectangle widget + */ + private async renderRectangleWidget( + width: number, + height: number, + config: Record, + ): Promise { + const fillColor = this.parseColor(config.fillColor || '#000000'); + const borderRadius = config.borderRadius || 0; + + // Create a simple rectangle (borderRadius would need SVG overlay for proper support) + return sharp({ + create: { + width, + height, + channels: 4, + background: fillColor, + }, + }) + .png() + .toBuffer(); + } + + /** + * Render WiFi widget with icon + */ + private async renderWifiWidget( + width: number, + height: number, + config: Record, + deviceContext?: DeviceContext, + ): Promise { + const fontSize = config.fontSize || 18; + const showStrength = config.showStrength !== false; + const showIcon = config.showIcon !== false; + const color = this.sanitizeColor(config.color || '#000000'); + + // Use real WiFi RSSI from device context, or show placeholder + const rssi = deviceContext?.wifi; + const rssiText = rssi !== undefined ? `${rssi} dBm` : '-- dBm'; + + // Calculate icon size based on font size + const iconSize = Math.max(24, fontSize * 1.4); + + // Calculate layout + const gap = 8; + let totalContentWidth = 0; + if (showIcon) totalContentWidth += iconSize; + if (showIcon && showStrength) totalContentWidth += gap; + if (showStrength) totalContentWidth += rssiText.length * fontSize * 0.6; + + const startX = (width - totalContentWidth) / 2; + const centerY = height / 2; + + // Build SVG elements + let svgContent = ''; + let currentX = startX; + + // WiFi icon (signal arcs) + if (showIcon) { + const iconCenterX = currentX + iconSize / 2; + const iconCenterY = centerY + iconSize * 0.2; + + // WiFi arcs (from outer to inner) + // Outer arc + svgContent += ` + + `; + + // Middle arc + svgContent += ` + + `; + + // Inner arc + svgContent += ` + + `; + + // Center dot + svgContent += ` + + `; + + currentX += iconSize + gap; + } + + // Signal strength text + if (showStrength) { + const textY = centerY + fontSize * 0.35; + svgContent += ` + + ${rssiText} + + `; + } + + const svg = ` + + ${svgContent} + + `; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } + + /** + * Render device info widget + */ + private async renderDeviceInfoWidget( + width: number, + height: number, + config: Record, + deviceContext?: DeviceContext, + ): Promise { + const fontSize = config.fontSize || 16; + const showName = config.showName ?? true; + const showFirmware = config.showFirmware ?? true; + const showMac = config.showMac ?? false; + + // Build info lines from real device context + const lines: string[] = []; + + if (showName) { + const name = deviceContext?.deviceName || 'Unknown'; + lines.push(name); + } + + if (showFirmware) { + const firmware = deviceContext?.firmwareVersion || '--'; + lines.push(`FW: ${firmware}`); + } + + if (showMac) { + const mac = deviceContext?.macAddress || '--'; + lines.push(mac); + } + + const text = lines.length > 0 ? lines.join('\n') : 'Device Info'; + + return this.renderTextToBuffer(text, width, height, { + fontSize, + fontFamily: config.fontFamily || 'sans-serif', + color: this.sanitizeColor(config.color || '#000000'), + textAlign: 'center', + }); + } + + /** + * Render days until widget + */ + private async renderDaysUntilWidget( + width: number, + height: number, + config: Record, + ): Promise { + const targetDateStr = config.targetDate || '2025-12-25'; + const fontSize = config.fontSize || 32; + + // New customizable prefix/suffix format + const labelPrefix = config.labelPrefix ?? 'Days till Christmas: '; + const labelSuffix = config.labelSuffix || ''; + + // Calculate days until target date + const targetDate = new Date(targetDateStr); + targetDate.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const diffTime = targetDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Format using prefix + number + suffix + const days = diffDays < 0 ? Math.abs(diffDays) : diffDays; + const text = `${labelPrefix}${days}${labelSuffix}`; + + this.logger.debug(`Rendering daysuntil: ${text}`); + + return this.renderTextToBuffer(text, width, height, { + fontSize, + fontFamily: config.fontFamily || 'sans-serif', + color: this.sanitizeColor(config.color || '#000000'), + textAlign: 'left', + noWrap: true, + }); + } + + /** + * Get GitHub repository stars - public API for frontend preview + */ + async getGitHubStars( + owner: string, + repo: string, + ): Promise<{ stars: number; name: string } | null> { + return this.fetchGitHubStars(owner, repo); + } + + /** + * Fetch GitHub repository stars with caching + */ + private async fetchGitHubStars( + owner: string, + repo: string, + ): Promise<{ stars: number; name: string } | null> { + const cacheKey = `${owner}/${repo}`.toLowerCase(); + + // Check cache first + const cached = this.githubCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.GITHUB_CACHE_TTL) { + this.logger.debug(`GitHub stars for ${owner}/${repo} served from cache`); + return cached.data; + } + + try { + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + + // Get GitHub token from database settings first, fallback to environment variable + let githubToken = await this.settingsService.getGitHubToken(); + if (!githubToken) { + githubToken = this.configService.get('github.token') ?? null; + } + + // Add timeout to prevent hanging + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const headers: Record = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Inker-E-Ink-Display', + }; + + if (githubToken) { + headers['Authorization'] = `Bearer ${githubToken}`; + this.logger.debug(`Fetching GitHub stars for ${owner}/${repo} with token`); + } else { + this.logger.debug(`Fetching GitHub stars for ${owner}/${repo} without token (rate limit: 60/hr)`); + } + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + const rateLimit = response.headers.get('x-ratelimit-remaining'); + const resetTime = response.headers.get('x-ratelimit-reset'); + this.logger.warn(`GitHub API error ${response.status} for ${owner}/${repo}. Rate limit remaining: ${rateLimit}, resets at: ${resetTime ? new Date(parseInt(resetTime) * 1000).toISOString() : 'unknown'}`); + + // If rate limited, return cached data even if expired + if (response.status === 403 && cached) { + this.logger.debug(`Returning expired cache for ${owner}/${repo} due to rate limit`); + return cached.data; + } + + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = await response.json(); + + const result = { + stars: data.stargazers_count || 0, + name: data.full_name || `${owner}/${repo}`, + }; + + // Store in cache and evict expired entries + const now = Date.now(); + this.githubCache.set(cacheKey, { data: result, timestamp: now }); + for (const [key, entry] of this.githubCache.entries()) { + if (now - entry.timestamp > this.GITHUB_CACHE_TTL * 2) { + this.githubCache.delete(key); + } + } + // Cap cache size by evicting oldest entries + if (this.githubCache.size > this.GITHUB_CACHE_MAX_SIZE) { + const sorted = [...this.githubCache.entries()].sort(([, a], [, b]) => a.timestamp - b.timestamp); + for (const [key] of sorted.slice(0, this.githubCache.size - this.GITHUB_CACHE_MAX_SIZE)) { + this.githubCache.delete(key); + } + } + this.logger.debug(`GitHub stars for ${owner}/${repo}: ${result.stars} (cached)`); + + return result; + } catch (error) { + this.logger.warn(`Failed to fetch GitHub stars: ${error instanceof Error ? error.message : String(error)}`); + + // Return expired cache if available + if (cached) { + this.logger.debug(`Returning expired cache for ${owner}/${repo} due to error`); + return cached.data; + } + + return null; + } + } + + /** + * Get GitHub star icon SVG + */ + private getGitHubStarIconSvg(size: number, color: string): string { + // Star icon path (5-point star) + const cx = size / 2; + const cy = size / 2; + const outerR = size * 0.45; + const innerR = size * 0.18; + + // Calculate star points + const points: string[] = []; + for (let i = 0; i < 5; i++) { + // Outer point (tip of star) + const outerAngle = (i * 72 - 90) * Math.PI / 180; + points.push(`${cx + Math.cos(outerAngle) * outerR},${cy + Math.sin(outerAngle) * outerR}`); + + // Inner point (between tips) + const innerAngle = ((i * 72) + 36 - 90) * Math.PI / 180; + points.push(`${cx + Math.cos(innerAngle) * innerR},${cy + Math.sin(innerAngle) * innerR}`); + } + + return ``; + } + + /** + * Render GitHub Stars widget + */ + private async renderGitHubWidget( + width: number, + height: number, + config: Record, + ): Promise { + const owner = config.owner || 'facebook'; + const repo = config.repo || 'react'; + const showIcon = config.showIcon !== false; + const showRepoName = config.showRepoName || false; + const fontSize = config.fontSize || 32; + const fontFamily = config.fontFamily || 'sans-serif'; + const color = this.sanitizeColor(config.color || '#000000'); + + // Fetch stars from GitHub API + const githubData = await this.fetchGitHubStars(owner, repo); + + if (!githubData) { + // Fallback if API fails + return this.renderTextToBuffer(`${owner}/${repo}\nUnavailable`, width, height, { + fontSize: fontSize * 0.7, + fontFamily, + color, + textAlign: 'center', + }); + } + + const { stars, name } = githubData; + + // Format star count (e.g., 1.2k, 15.3k) + let starsText: string; + if (stars >= 1000000) { + starsText = `${(stars / 1000000).toFixed(1)}M`; + } else if (stars >= 1000) { + starsText = `${(stars / 1000).toFixed(1)}k`; + } else { + starsText = String(stars); + } + + // Build SVG + const iconSize = showIcon ? Math.min(fontSize * 1.2, height * 0.6) : 0; + const gap = showIcon ? 8 : 0; + + // Estimate text width + const textWidth = starsText.length * fontSize * 0.6; + const totalContentWidth = iconSize + gap + textWidth; + const startX = (width - totalContentWidth) / 2; + + let svgContent = ''; + + // Calculate vertical center for main content + const mainContentY = showRepoName ? height * 0.4 : height / 2; + + // Star icon + if (showIcon) { + const iconY = mainContentY - iconSize / 2; + svgContent += ` + + ${this.getGitHubStarIconSvg(iconSize, color)} + + `; + } + + // Stars count + const textX = startX + (showIcon ? iconSize + gap : 0); + const textY = mainContentY + fontSize * 0.35; + svgContent += ` + + ${starsText} + + `; + + // Repo name at bottom + if (showRepoName) { + const smallFontSize = Math.max(12, fontSize * 0.4); + svgContent += ` + + ${this.escapeXml(name)} + + `; + } + + const svg = ` + + ${svgContent} + + `; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } + + /** + * Render image widget + * Processes image for e-ink display (grayscale, high contrast) + */ + private async renderImageWidget( + width: number, + height: number, + config: Record, + ): Promise { + const url = config.url; + const fit = config.fit || 'contain'; + + if (!url) { + return this.renderPlaceholderWidget(width, height, 'No Image'); + } + + try { + let imageBuffer: Buffer; + + // Check if it's a local file path (starts with /uploads/) + if (url.startsWith('/uploads/')) { + // Read directly from filesystem (with path traversal protection) + const fs = await import('fs/promises'); + const filePath = this.resolveUploadPath(url); + imageBuffer = await fs.readFile(filePath); + } else { + // Validate URL for SSRF before fetching (blocks private IPs, file://, etc.) + await validateUrlSafety(url); + + // Fetch remote image with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + + imageBuffer = Buffer.from(await response.arrayBuffer()); + } + + // Process for e-ink: flatten transparent to white, grayscale, high contrast, resize + // IMPORTANT: flatten() must come BEFORE grayscale() to convert transparent pixels to white + return sharp(imageBuffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .grayscale() + .normalize() // Enhances contrast for e-ink + .resize(width, height, { + fit: fit as keyof FitEnum, + background: { r: 255, g: 255, b: 255, alpha: 1 }, // White background for e-ink + }) + .png() + .toBuffer(); + } catch (error) { + this.logger.warn(`Failed to load image from ${url}: ${error instanceof Error ? error.message : String(error)}`); + return this.renderPlaceholderWidget(width, height, 'Image Error'); + } + } + + /** + * Render custom widget using Puppeteer for 1:1 HTML/CSS rendering + * Generates the exact same HTML as the frontend WidgetRenderer component + * Font settings (fontSize, fontFamily, fontWeight, textAlign, color) are read from config + */ + private async renderCustomWidget( + width: number, + height: number, + config: Record, + ): Promise { + const customWidgetId = config.customWidgetId as number | undefined; + + // Font settings from screen designer config + const fontSize = config.fontSize || 24; + const fontFamily = config.fontFamily || 'sans-serif'; + const fontWeight = config.fontWeight || 'normal'; + const textAlign = config.textAlign || 'center'; + const verticalAlign = config.verticalAlign || 'middle'; + const color = this.sanitizeColor(config.color || '#000000'); + + // Per-cell overrides from screen designer + const cellOverrides = (config.cellOverrides as Record>) || {}; + + if (!customWidgetId) { + this.logger.warn('Custom widget missing customWidgetId in config'); + return this.renderPlaceholderWidget(width, height, 'No Widget ID'); + } + + try { + // Fetch the custom widget with its rendered data + const preview = await this.customWidgetsService.getWithData(customWidgetId); + const { widget, renderedContent } = preview; + const widgetConfig = widget.config as Record; + const fieldType = widgetConfig.fieldType as string | undefined; + const valueFieldType = widgetConfig.valueFieldType as string | undefined; + + this.logger.debug(`Rendering custom widget ${customWidgetId}: displayType=${widget.displayType}, fontSize=${fontSize}`); + + // Generate the exact same HTML as the frontend WidgetRenderer (async to process images) + const html = await this.generateCustomWidgetHtml( + width, + height, + renderedContent, + { fontSize, fontFamily, fontWeight, textAlign, verticalAlign, color }, + { fieldType, valueFieldType }, + cellOverrides, + ); + + // Render HTML to PNG using Puppeteer + return await this.renderHtmlToPng(html, width, height); + } catch (error) { + this.logger.error(`Failed to render custom widget ${customWidgetId}:`, error); + return this.renderPlaceholderWidget(width, height, 'Error'); + } + } + + /** + * Generate HTML that matches the frontend WidgetRenderer component exactly + * Processes image URLs for e-ink display (grayscale, high contrast) + */ + private async generateCustomWidgetHtml( + width: number, + height: number, + renderedContent: any, + styles: { fontSize: number; fontFamily: string; fontWeight: string; textAlign: string; verticalAlign: string; color: string }, + fieldTypes: { fieldType?: string; valueFieldType?: string }, + cellOverrides: Record> = {}, + ): Promise { + const { fontSize, fontWeight, textAlign, verticalAlign, color } = styles; + // Apply font family mapping to use loaded fonts + const fontFamily = this.mapFontFamily(styles.fontFamily); + + // Map alignment values to CSS flexbox properties + const alignItems = textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'; + const justifyContent = verticalAlign === 'top' ? 'flex-start' : verticalAlign === 'bottom' ? 'flex-end' : 'center'; + const { fieldType, valueFieldType } = fieldTypes; + + // Generate inner content based on content type - exact match to frontend + let innerHtml = ''; + + if (typeof renderedContent === 'string') { + if (fieldType === 'image') { + // Process image for e-ink display (grayscale, high contrast) + const processedImageUrl = await this.processImageForEink(renderedContent); + innerHtml = `Custom widget image`; + } else { + innerHtml = `
${this.escapeHtml(renderedContent)}
`; + } + } else if (Array.isArray(renderedContent)) { + // List items already have prefix (•, -, 1.) from renderList(), so no list-style needed + // Use full fontSize to match frontend WidgetRenderer.tsx + // Allow text wrapping (no white-space: nowrap) to match designer behavior + const listItems = renderedContent.slice(0, 10).map(item => + `
  • ${this.escapeHtml(String(item))}
  • ` + ).join(''); + innerHtml = `
      ${listItems}
    `; + } else if (typeof renderedContent === 'object' && renderedContent !== null) { + // Check if it's a grid display type + if ('type' in renderedContent && renderedContent.type === 'grid') { + const gridContent = renderedContent as { + type: 'grid'; + gridCols: number; + gridRows: number; + gridGap: number; + cells: Array<{ + row: number; + col: number; + field: string; + fieldType: string; + label?: string; + value: unknown; + formattedValue: string; + align?: string; + verticalAlign?: string; + }>; + }; + + const cellFontSize = fontSize * 0.7; + const labelFontSize = cellFontSize * 0.6; + + // Pre-process all image URLs in parallel for e-ink display + const processedCells = await Promise.all( + gridContent.cells.map(async (cell) => { + let processedImageUrl: string | null = null; + if (cell.fieldType === 'image' && cell.value) { + processedImageUrl = await this.processImageForEink(String(cell.value)); + } + return { ...cell, processedImageUrl }; + }) + ); + + // Build grid cells HTML with processed images + const cellsHtml = processedCells.map(cell => { + const cellKey = `${cell.row}-${cell.col}`; + // Get cell-specific overrides from screen designer config + const override = cellOverrides[cellKey] || {}; + + // Use override values or fall back to defaults + const cellFontSizeOverride = (override.fontSize as number) || cellFontSize; + const cellFontWeightOverride = (override.fontWeight as string) || 'bold'; + // Apply font mapping to cell override or use the already-mapped parent fontFamily + const cellFontFamilyOverride = override.fontFamily + ? this.mapFontFamily(override.fontFamily as string) + : fontFamily; + const cellAlign = (override.align as string) || cell.align || 'center'; + const cellVerticalAlign = (override.verticalAlign as string) || cell.verticalAlign || 'middle'; + const imageFit = (override.imageFit as string) || 'contain'; + + // Map horizontal alignment to flexbox align-items + const alignItems = cellAlign === 'left' ? 'flex-start' : cellAlign === 'right' ? 'flex-end' : 'center'; + // Map vertical alignment to flexbox justify-content + const justifyContent = cellVerticalAlign === 'top' ? 'flex-start' : cellVerticalAlign === 'bottom' ? 'flex-end' : 'center'; + + const labelFontSizeForCell = cellFontSizeOverride * 0.6; + const labelHtml = cell.label + ? `
    ${this.escapeHtml(cell.label)}
    ` + : ''; + + let valueHtml: string; + if (cell.fieldType === 'image' && cell.processedImageUrl) { + valueHtml = `${this.escapeHtml(cell.label || 'Grid cell')}`; + } else { + valueHtml = `${this.escapeHtml(cell.formattedValue)}`; + } + + return ` +
    + ${labelHtml} + ${valueHtml} +
    + `; + }).join(''); + + innerHtml = ` +
    + ${cellsHtml} +
    + `; + } else if (('title' in renderedContent || 'label' in renderedContent) && 'value' in renderedContent) { + const label = String('title' in renderedContent ? renderedContent.title : renderedContent.label); + const value = String(renderedContent.value); + + if (valueFieldType === 'image') { + // Process image for e-ink display (grayscale, high contrast) + const processedImageUrl = await this.processImageForEink(value); + innerHtml = ` +
    +
    ${this.escapeHtml(label)}
    +
    + Custom widget image +
    +
    + `; + } else { + innerHtml = ` +
    +
    ${this.escapeHtml(label)}
    +
    ${this.escapeHtml(value)}
    +
    + `; + } + } else { + // Generic object - render as JSON + innerHtml = `
    ${this.escapeHtml(JSON.stringify(renderedContent, null, 2))}
    `; + } + } else { + innerHtml = `Invalid content`; + } + + // Full HTML document with exact same CSS as frontend + return ` + + + + + ${this.fontStyleTag} + + + +
    + ${innerHtml} +
    + + + `; + } + + /** + * Render HTML to PNG using Puppeteer + */ + async renderHtmlToPng(html: string, width: number, height: number): Promise { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + + try { + // Block all outbound network requests — all content should be inline/data URLs + await page.setRequestInterception(true); + page.on('request', (request) => { + const reqUrl = request.url(); + if (reqUrl.startsWith('data:') || reqUrl === 'about:blank') { + request.continue(); + } else { + request.abort('blockedbyclient'); + } + }); + + // Set viewport to exact widget size + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + + // Load HTML content + // Use 'load' to wait for all resources (images) to finish loading + // This is important for custom widgets that contain embedded base64 images + await page.setContent(html, { waitUntil: 'load', timeout: 10000 }); + + // Wait for fonts to load (with timeout fallback) + await Promise.race([ + page.evaluateHandle('document.fonts.ready'), + new Promise(resolve => setTimeout(resolve, 500)), + ]); + + // Take screenshot with transparent background + const screenshot = await page.screenshot({ + type: 'png', + omitBackground: true, + }); + + return Buffer.from(screenshot); + } finally { + await page.close(); + } + } + + /** + * Render a URL to PNG using Puppeteer + */ + async renderUrlToPng(url: string, width: number, height: number): Promise { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + + try { + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); + + const screenshot = await page.screenshot({ type: 'png', fullPage: false }); + return Buffer.from(screenshot); + } finally { + await page.close(); + } + } + + /** + * Escape HTML special characters + */ + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Helper to render an image from URL (reused from renderImageWidget logic) + */ + private async renderImageFromUrl( + url: string, + width: number, + height: number, + ): Promise { + if (!url) { + return this.renderPlaceholderWidget(width, height, 'No Image URL'); + } + + try { + let imageBuffer: Buffer; + + // Check if it's a local file path (starts with /uploads/) + if (url.startsWith('/uploads/')) { + // Read directly from filesystem (with path traversal protection) + const fs = await import('fs/promises'); + const filePath = this.resolveUploadPath(url); + imageBuffer = await fs.readFile(filePath); + } else { + // Validate URL for SSRF before fetching (blocks private IPs, file://, etc.) + await validateUrlSafety(url); + + // Add timeout to prevent hanging on slow/unresponsive image servers + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + + imageBuffer = Buffer.from(await response.arrayBuffer()); + } + + // Resize and fit the image with transparent background + return sharp(imageBuffer) + .resize(width, height, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + } catch (error) { + this.logger.warn(`Failed to load image from ${url}: ${error instanceof Error ? error.message : String(error)}`); + return this.renderPlaceholderWidget(width, height, 'Image Error'); + } + } + + /** + * Process an image URL for e-ink display and return base64 data URL + * Applies grayscale conversion and high contrast for better e-ink rendering + */ + private async processImageForEink(url: string): Promise { + if (!url) { + return ''; + } + + try { + let imageBuffer: Buffer; + + // Check if it's a local file path (starts with /uploads/) + if (url.startsWith('/uploads/')) { + // Read directly from filesystem (with path traversal protection) + const fs = await import('fs/promises'); + const filePath = this.resolveUploadPath(url); + imageBuffer = await fs.readFile(filePath); + } else { + // Add timeout to prevent hanging on slow/unresponsive image servers + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + + imageBuffer = Buffer.from(await response.arrayBuffer()); + } + + // Process image for e-ink: grayscale with high contrast + // IMPORTANT: flatten() converts transparent backgrounds to white + // Without this, transparent pixels become black after processing + const processedBuffer = await sharp(imageBuffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .grayscale() + .normalize() // Enhances contrast + .png() + .toBuffer(); + + // Convert to base64 data URL + const base64 = processedBuffer.toString('base64'); + return `data:image/png;base64,${base64}`; + } catch (error) { + this.logger.warn(`Failed to process image for e-ink from ${url}: ${error instanceof Error ? error.message : String(error)}`); + return ScreenRendererService.FALLBACK_PIXEL; + } + } + + /** + * Render a placeholder widget + */ + private async renderPlaceholderWidget( + width: number, + height: number, + label: string, + ): Promise { + return this.renderTextToBuffer(label, width, height, { + fontSize: 14, + fontFamily: 'sans-serif', + color: '#666666', + textAlign: 'center', + }); + } + + /** + * Render text to a buffer using SVG with proper word wrapping + * + * Options: + * - noWrap: Don't word-wrap text (only split on explicit newlines) + * - clipContent: Use SVG clipPath to clip content to widget bounds (like CSS overflow:hidden) + * When true, renders all content at natural size and clips what doesn't fit + */ + private async renderTextToBuffer( + text: string, + width: number, + height: number, + options: { + fontSize: number; + fontFamily: string; + fontWeight?: string; + color: string; + textAlign: string; + noWrap?: boolean; + clipContent?: boolean; + }, + ): Promise { + const { fontSize, fontFamily, fontWeight, color, textAlign, noWrap, clipContent } = options; + + // Calculate text anchor based on alignment + const textAnchor = + textAlign === 'center' ? 'middle' : textAlign === 'right' ? 'end' : 'start'; + const xPos = + textAlign === 'center' + ? width / 2 + : textAlign === 'right' + ? width - 10 + : 10; + + // Padding from edges + const padding = 5; + const availableWidth = width - (padding * 2); + + // Estimate character width (approximate for monospace/sans-serif) + // Using 0.5 as average - this is more conservative to prevent premature wrapping + // Monospace fonts are typically ~0.6, proportional fonts vary from 0.4-0.7 + const avgCharWidth = fontSize * 0.5; + const maxCharsPerLine = Math.max(10, Math.floor(availableWidth / avgCharWidth)); + + // Word wrap text (skip if noWrap or clipContent is true) + const wrappedLines: string[] = []; + const inputLines = text.split('\n'); + + if (noWrap || clipContent) { + // No wrapping - keep lines as-is (only split on explicit newlines) + wrappedLines.push(...inputLines); + } else { + for (const line of inputLines) { + if (line.length <= maxCharsPerLine) { + wrappedLines.push(line); + } else { + // Word wrap this line + const words = line.split(' '); + let currentLine = ''; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (testLine.length <= maxCharsPerLine) { + currentLine = testLine; + } else { + if (currentLine) { + wrappedLines.push(currentLine); + } + // If single word is too long, split it + if (word.length > maxCharsPerLine) { + let remaining = word; + while (remaining.length > maxCharsPerLine) { + wrappedLines.push(remaining.substring(0, maxCharsPerLine)); + remaining = remaining.substring(maxCharsPerLine); + } + currentLine = remaining; + } else { + currentLine = word; + } + } + } + if (currentLine) { + wrappedLines.push(currentLine); + } + } + } + } + + const lineHeight = fontSize * 1.2; + const totalTextHeight = wrappedLines.length * lineHeight; + + // Calculate starting Y position for vertical centering + // SVG text y-coordinate is at the baseline + // To center text visually, we need: baseline = center + (ascender - descender) / 2 + // For most fonts: ascender ~= 0.75 * fontSize, descender ~= 0.25 * fontSize + // Visual center offset from baseline ~= 0.35 * fontSize (upward) + // So baseline should be at: widgetCenter + 0.35 * fontSize + + let startY: number; + if (totalTextHeight <= height) { + // For single/multi-line text that fits: + // Calculate where the first line's baseline should be + const numLines = wrappedLines.length; + const textBlockHeight = numLines * lineHeight; + const firstLineCenter = (height - textBlockHeight) / 2 + lineHeight / 2; + // Baseline is below center by ~0.35 * fontSize + startY = firstLineCenter + fontSize * 0.35; + } else { + // For text that doesn't fit, start from top + startY = fontSize * 0.85; + } + + // Determine which lines to render + let linesToRender: string[]; + if (clipContent) { + // Render all lines - SVG viewport will clip overflowing content + linesToRender = wrappedLines; + } else { + // Only render lines that fit within height (but always render at least 1 line) + const maxLines = Math.max(1, Math.floor(height / lineHeight)); + linesToRender = wrappedLines.slice(0, maxLines); + } + + const textElements = linesToRender + .map((line, i) => { + const y = startY + i * lineHeight; + const escapedLine = this.escapeXml(line); + return `${escapedLine}`; + }) + .join('\n'); + + // Use transparent background so widgets can overlay each other + // viewBox + clipPath ensures content outside widget bounds is clipped (like CSS overflow:hidden) + const svg = ` + + + + + + + + ${textElements} + + + `; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } + + /** + * Parse hex color to RGBA object + */ + private parseColor(hex: string): { r: number; g: number; b: number; alpha: number } { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + alpha: 1, + }; + } + return { r: 255, g: 255, b: 255, alpha: 1 }; + } + + /** + * Sanitize a color value for safe CSS/SVG interpolation. + * Only allows valid hex colors (#RGB, #RRGGBB, #RRGGBBAA). + */ + private sanitizeColor(color: string, fallback = '#000000'): string { + if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(color)) return color; + return fallback; + } + + /** + * Escape XML special characters + */ + private escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // ============================================================ + // HTML-BASED RENDERING (Pixel-Perfect) + // Uses Puppeteer to render HTML matching frontend WidgetRenderer.tsx + // ============================================================ + + /** + * Render screen design using HTML/CSS + Puppeteer for pixel-perfect output + * This matches exactly what the frontend shows in the designer + */ + async renderDesignAsHtml(screenDesign: ScreenDesignWithWidgets, deviceContext?: DeviceContext): Promise { + const { width, height, background, widgets } = screenDesign; + const startTime = Date.now(); + + // Log widget positions for debugging + this.logger.debug(`Rendering ${widgets.length} widgets for screen ${screenDesign.id} (${width}x${height})`); + widgets.forEach((w, i) => { + const crossesEdge = w.x < 0 || w.y < 0 || (w.x + w.width) > width || (w.y + w.height) > height; + this.logger.debug(` Widget ${i}: ${w.template.name} at (${w.x}, ${w.y}) size ${w.width}x${w.height}${crossesEdge ? ' [CROSSES EDGE]' : ''}`); + }); + + // Generate HTML for all widgets + const widgetsHtml = await Promise.all( + widgets.map(widget => this.generateWidgetHtml(widget, deviceContext)) + ); + const htmlGenTime = Date.now(); + this.logger.debug(` HTML generation took ${htmlGenTime - startTime}ms`); + + // Create full HTML page matching frontend structure + const html = ` + + + + + ${this.fontStyleTag} + + + + ${widgetsHtml.join('\n ')} + +`; + + // Render with Puppeteer + const browser = await this.getBrowser(); + const page = await browser.newPage(); + + try { + // Block all outbound network requests — all content is pre-fetched and inline + await page.setRequestInterception(true); + page.on('request', (request) => { + const reqUrl = request.url(); + if (reqUrl.startsWith('data:') || reqUrl === 'about:blank') { + request.continue(); + } else { + request.abort('blockedbyclient'); + } + }); + + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 }); + + // Wait for fonts and all images to load (with timeout fallback) + await Promise.race([ + Promise.all([ + page.evaluateHandle('document.fonts.ready'), + page.evaluate(() => { + const images = Array.from(document.querySelectorAll('img')); + return Promise.all( + images.map(img => + img.complete + ? Promise.resolve() + : new Promise(resolve => { + img.onload = resolve; + img.onerror = resolve; + }) + ) + ); + }), + ]), + new Promise(resolve => setTimeout(resolve, 5000)), + ]); + + const screenshot = await page.screenshot({ + type: 'png', + clip: { x: 0, y: 0, width, height }, + }); + const screenshotTime = Date.now(); + this.logger.debug(` Puppeteer screenshot took ${screenshotTime - htmlGenTime}ms (total: ${screenshotTime - startTime}ms)`); + + return Buffer.from(screenshot); + } finally { + await page.close(); + } + } + + /** + * Generate HTML for a single widget matching frontend WidgetRenderer.tsx + */ + private async generateWidgetHtml(widget: WidgetWithTemplate, deviceContext?: DeviceContext): Promise { + const { x, y, width, height, config, template } = widget; + const widgetConfig = config as Record; + + // Get widget-specific content + let content = ''; + let extraStyles = ''; + + switch (template.name) { + case 'clock': + content = this.generateClockContent(widgetConfig); + extraStyles = this.getClockStyles(widgetConfig); + break; + case 'date': + content = this.generateDateContent(widgetConfig); + extraStyles = this.getDateStyles(widgetConfig); + break; + case 'text': + content = this.generateTextContent(widgetConfig); + extraStyles = this.getTextStyles(widgetConfig); + break; + case 'daysuntil': + content = this.generateDaysUntilContent(widgetConfig); + extraStyles = this.getDaysUntilStyles(widgetConfig); + break; + case 'countdown': + content = this.generateCountdownContent(widgetConfig); + extraStyles = this.getCountdownStyles(widgetConfig); + break; + case 'weather': + content = await this.generateWeatherContent(widgetConfig); + extraStyles = this.getWeatherStyles(widgetConfig); + break; + case 'qrcode': + content = await this.generateQRCodeContent(widgetConfig, Math.min(width, height)); + break; + case 'battery': + content = this.generateBatteryContent(widgetConfig, deviceContext); + extraStyles = this.getBatteryStyles(widgetConfig); + break; + case 'wifi': + content = this.generateWifiContent(widgetConfig, deviceContext); + extraStyles = this.getWifiStyles(widgetConfig); + break; + case 'deviceinfo': + content = this.generateDeviceInfoContent(widgetConfig, deviceContext); + extraStyles = this.getDeviceInfoStyles(widgetConfig); + break; + case 'image': + content = await this.generateImageContent(widgetConfig, width, height); + break; + case 'divider': + content = this.generateDividerContent(widgetConfig, width, height); + break; + case 'rectangle': + content = this.generateRectangleContent(widgetConfig); + break; + case 'github': + content = await this.generateGitHubContent(widgetConfig); + extraStyles = this.getGitHubStyles(widgetConfig); + break; + case 'custom-widget-base': + content = await this.generateCustomWidgetContent(widgetConfig, width, height); + extraStyles = this.getCustomWidgetStyles(widgetConfig); + break; + default: + content = `
    Unknown: ${template.name}
    `; + } + + // Handle rotation, z-index, and opacity + const rotation = (widget as any).rotation || 0; + const zIndex = widget.zIndex || 0; + const opacity = (widgetConfig.opacity as number) ?? 100; + const rotationStyle = rotation !== 0 ? `transform: rotate(${rotation}deg); transform-origin: center center;` : ''; + const opacityStyle = opacity < 100 ? `opacity: ${opacity / 100};` : ''; + + return `
    ${content}
    `; + } + + // ----- Clock Widget ----- + private generateClockContent(config: Record): string { + const now = new Date(); + const format = config.format || '24h'; + const showSeconds = config.showSeconds || false; + const timezone = config.timezone || 'local'; + const defaultTimezone = this.configService.get('defaultTimezone', 'UTC'); + const effectiveTimezone = (timezone === 'local' || timezone === '') ? defaultTimezone : timezone; + + const options: Intl.DateTimeFormatOptions = { + timeZone: effectiveTimezone, + hour: '2-digit', + minute: '2-digit', + second: showSeconds ? '2-digit' : undefined, + hour12: format === '12h', + }; + + return this.escapeHtml(now.toLocaleTimeString('en-US', options)); + } + + private getClockStyles(config: Record): string { + const fontSize = config.fontSize || 48; + const fontFamily = this.mapFontFamily(config.fontFamily || 'monospace'); + const textAlign = config.textAlign || 'left'; + const justifyContent = textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'; + return `font-size: ${fontSize}px; font-family: ${fontFamily}; justify-content: ${justifyContent}; white-space: nowrap; padding: 0 8px;`; + } + + // ----- Date Widget ----- + private generateDateContent(config: Record): string { + const now = new Date(); + const locale = config.locale || 'en-US'; + const showWeekday = config.showWeekday ?? config.showDayOfWeek ?? false; + const showDay = config.showDay ?? true; + const showMonth = config.showMonth ?? true; + const showYear = config.showYear ?? true; + const timezone = config.timezone || ''; + const defaultTimezone = this.configService.get('defaultTimezone', 'UTC'); + const effectiveTimezone = (timezone === 'local' || timezone === '') ? defaultTimezone : timezone; + + this.logger.debug(`Date widget: config.timezone="${timezone}", effectiveTimezone="${effectiveTimezone}"`); + + const options: Intl.DateTimeFormatOptions = { timeZone: effectiveTimezone }; + if (showWeekday) options.weekday = 'long'; + if (showDay) options.day = 'numeric'; + if (showMonth) options.month = 'long'; + if (showYear) options.year = 'numeric'; + if (!showWeekday && !showDay && !showMonth && !showYear) { + options.day = 'numeric'; + options.month = 'long'; + options.year = 'numeric'; + } + + const dateStr = now.toLocaleDateString(locale, options); + this.logger.debug(`Date widget: rendered "${dateStr}"`); + + return this.escapeHtml(dateStr); + } + + private getDateStyles(config: Record): string { + const fontSize = config.fontSize || 24; + const fontFamily = this.mapFontFamily(config.fontFamily || 'sans-serif'); + const textAlign = config.textAlign || 'center'; + const justifyContent = textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'; + const lineHeight = fontSize * 1.2; + return `font-size: ${fontSize}px; font-family: ${fontFamily}; line-height: ${lineHeight}px; white-space: nowrap; padding: 0 8px; justify-content: ${justifyContent};`; + } + + // ----- Text Widget ----- + private generateTextContent(config: Record): string { + const text = config.text || 'Text'; + return `
    ${this.escapeHtml(text)}
    `; + } + + private getTextStyles(config: Record): string { + const fontSize = config.fontSize || 24; + const fontFamily = this.mapFontFamily(config.fontFamily || 'sans-serif'); + const fontWeight = config.fontWeight || 'normal'; + const color = this.sanitizeColor(config.color || '#000000'); + return `font-size: ${fontSize}px; font-family: ${fontFamily}; font-weight: ${fontWeight}; color: ${color}; padding: 10px; line-height: 1.2;`; + } + + // ----- Days Until Widget ----- + private generateDaysUntilContent(config: Record): string { + const targetDateStr = config.targetDate || '2025-12-25'; + const labelPrefix = config.labelPrefix ?? 'Days till Christmas: '; + const labelSuffix = config.labelSuffix || ''; + + const targetDate = new Date(targetDateStr); + targetDate.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const diffTime = targetDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + const days = diffDays < 0 ? Math.abs(diffDays) : diffDays; + + return this.escapeHtml(`${labelPrefix}${days}${labelSuffix}`); + } + + private getDaysUntilStyles(config: Record): string { + const fontSize = config.fontSize || 32; + const fontFamily = this.mapFontFamily(config.fontFamily || 'sans-serif'); + const color = this.sanitizeColor(config.color || '#000000'); + return `font-size: ${fontSize}px; font-family: ${fontFamily}; color: ${color}; white-space: nowrap; padding: 0 8px;`; + } + + // ----- Countdown Widget ----- + private generateCountdownContent(config: Record): string { + const targetDateStr = config.targetDate || '2025-12-31T23:59:59'; + const label = config.label || ''; + const showDays = config.showDays !== false; + const showHours = config.showHours !== false; + const showMinutes = config.showMinutes !== false; + const showSeconds = config.showSeconds !== false; + + const now = new Date(); + const targetDate = new Date(targetDateStr); + const diffMs = targetDate.getTime() - now.getTime(); + + if (diffMs <= 0) { + return this.escapeHtml(label || "Time's up!"); + } + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diffMs % (1000 * 60)) / 1000); + + const parts: string[] = []; + if (showDays && days > 0) parts.push(`${days}d`); + if (showHours) parts.push(`${hours.toString().padStart(2, '0')}h`); + if (showMinutes) parts.push(`${minutes.toString().padStart(2, '0')}m`); + if (showSeconds) parts.push(`${seconds.toString().padStart(2, '0')}s`); + + const fontSize = config.fontSize || 32; + let html = ''; + if (label) { + html += `
    ${this.escapeHtml(label)}
    `; + } + html += `
    ${this.escapeHtml(parts.join(' '))}
    `; + return html; + } + + private getCountdownStyles(config: Record): string { + const fontSize = config.fontSize || 32; + const fontFamily = this.mapFontFamily(config.fontFamily || 'monospace'); + return `font-size: ${fontSize}px; font-family: ${fontFamily}; flex-direction: column; justify-content: center; white-space: nowrap; padding: 0 8px;`; + } + + // ----- Weather Widget ----- + private async generateWeatherContent(config: Record): Promise { + const location = config.location || 'Unknown'; + const latitude = config.latitude || 52.2297; + const longitude = config.longitude || 21.0122; + const units = config.units || 'metric'; + const forecastDay = config.forecastDay || 0; + const forecastTime = config.forecastTime || 'current'; + const showIcon = config.showIcon !== false; + const showTemperature = config.showTemperature !== false; + const showCondition = config.showCondition !== false; + const showLocation = config.showLocation !== false; + const showHumidity = config.showHumidity as boolean; + const showWind = config.showWind as boolean; + const showDayName = config.showDayName as boolean; + const fontSize = config.fontSize || 32; + + try { + const weatherData = await this.fetchWeatherData(latitude, longitude, forecastDay, forecastTime); + if (!weatherData) { + return `
    ${this.escapeHtml(location)}
    Weather unavailable
    `; + } + + const condition = this.getWeatherCondition(weatherData.weatherCode); + const displayTemp = units === 'imperial' + ? Math.round((weatherData.temperature * 9/5) + 32) + : weatherData.temperature; + const tempUnit = units === 'imperial' ? '°F' : '°C'; + const windUnit = units === 'imperial' ? 'mph' : 'km/h'; + const displayWind = units === 'imperial' ? Math.round(weatherData.windSpeed * 0.621) : weatherData.windSpeed; + + const iconSize = Math.min(fontSize * 1.5, 48); + const smallFontSize = Math.max(10, fontSize * 0.4); + + let html = '
    '; + + if (showDayName && forecastDay > 0) { + html += `
    ${weatherData.dayName}
    `; + } + + html += '
    '; + if (showIcon) { + html += `
    ${this.getWeatherIconSvg(condition.icon, iconSize, 'currentColor')}
    `; + } + html += '
    '; + if (showTemperature) { + html += `
    ${displayTemp}${tempUnit}
    `; + } + if (showCondition) { + html += `
    ${condition.text}
    `; + } + html += '
    '; + + if (showHumidity) { + html += `
    Humidity: ${weatherData.humidity}%
    `; + } + if (showWind) { + html += `
    Wind: ${displayWind} ${windUnit}
    `; + } + if (showLocation) { + html += `
    ${this.escapeHtml(location)}
    `; + } + + html += '
    '; + return html; + } catch { + return `
    ${this.escapeHtml(location)}
    Weather unavailable
    `; + } + } + + private getWeatherStyles(config: Record): string { + return 'justify-content: center; padding: 8px;'; + } + + // ----- QR Code Widget ----- + private async generateQRCodeContent(config: Record, size: number): Promise { + const content = config.content || 'https://example.com'; + const qrSize = config.size || Math.min(size - 20, 100); + try { + const qrDataUrl = await QRCode.toDataURL(content, { + width: qrSize, + margin: 1, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, + }); + return `
    + QR +
    ${this.escapeHtml(content.length > 30 ? content.substring(0, 30) + '...' : content)}
    +
    `; + } catch { + return `
    QR Code
    `; + } + } + + // ----- Battery Widget ----- + private generateBatteryContent(config: Record, deviceContext?: DeviceContext): string { + const showPercentage = config.showPercentage as boolean; + const showIcon = config.showIcon as boolean; + const batteryLevel = deviceContext?.battery ?? 85; + + let html = '
    '; + if (showIcon) { + const fillWidth = Math.round((batteryLevel / 100) * 14); + html += ` + + + + `; + } + if (showPercentage) { + html += `${batteryLevel}%`; + } + html += '
    '; + return html; + } + + private getBatteryStyles(config: Record): string { + const fontSize = config.fontSize || 16; + return `font-size: ${fontSize}px; justify-content: center;`; + } + + // ----- WiFi Widget ----- + private generateWifiContent(config: Record, deviceContext?: DeviceContext): string { + const showStrength = config.showStrength !== false; + const showIcon = config.showIcon !== false; + const signalStrength = deviceContext?.wifi ?? -55; + + let html = '
    '; + if (showIcon) { + html += ` + + + + + `; + } + if (showStrength) { + html += `${signalStrength} dBm`; + } + html += '
    '; + return html; + } + + private getWifiStyles(config: Record): string { + const fontSize = config.fontSize || 16; + return `font-size: ${fontSize}px; justify-content: center;`; + } + + // ----- Device Info Widget ----- + private generateDeviceInfoContent(config: Record, deviceContext?: DeviceContext): string { + const showName = config.showName !== false; + const showFirmware = config.showFirmware !== false; + const showMac = config.showMac as boolean; + + const deviceName = deviceContext?.deviceName || 'TRMNL Device'; + const firmware = deviceContext?.firmwareVersion || 'v1.0.0'; + const mac = deviceContext?.macAddress || 'AA:BB:CC:DD:EE:FF'; + + let html = '
    '; + if (showName) html += `
    ${this.escapeHtml(deviceName)}
    `; + if (showFirmware) html += `
    Firmware: ${this.escapeHtml(firmware)}
    `; + if (showMac) html += `
    ${this.escapeHtml(mac)}
    `; + html += '
    '; + return html; + } + + private getDeviceInfoStyles(config: Record): string { + const fontSize = config.fontSize || 14; + return `font-size: ${fontSize}px; justify-content: center;`; + } + + // ----- Image Widget ----- + private async generateImageContent(config: Record, width: number, height: number): Promise { + const url = config.url || config.imageUrl || ''; + const fit = config.fit || 'contain'; + + if (!url) { + return '
    No image URL
    '; + } + + // Convert ALL images to base64 data URLs so Puppeteer never makes outbound requests + let imageUrl: string; + if (url.startsWith('/uploads/')) { + try { + const fs = await import('fs/promises'); + const filePath = this.resolveUploadPath(url); + const imageBuffer = await fs.readFile(filePath); + const base64 = imageBuffer.toString('base64'); + const ext = path.extname(url).slice(1) || 'png'; + imageUrl = `data:image/${ext};base64,${base64}`; + } catch { + return '
    Image not found
    '; + } + } else { + // Pre-fetch remote images server-side with SSRF validation + try { + await validateUrlSafety(url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + const base64 = buffer.toString('base64'); + const contentType = response.headers.get('content-type') || 'image/png'; + imageUrl = `data:${contentType};base64,${base64}`; + } catch { + return '
    Image unavailable
    '; + } + } + + return `
    + Image +
    `; + } + + // ----- Divider Widget ----- + private generateDividerContent(config: Record, width: number, height: number): string { + const orientation = config.orientation || 'horizontal'; + const thickness = config.thickness || 2; + const color = this.sanitizeColor(config.color || '#000000'); + const style = config.style || 'solid'; + + const isHorizontal = orientation === 'horizontal'; + const lineStyle = style === 'solid' + ? `background-color: ${color};` + : `border-style: ${style}; border-color: ${color}; border-width: ${thickness}px; background-color: transparent;`; + + return `
    +
    +
    `; + } + + // ----- Rectangle Widget ----- + private generateRectangleContent(config: Record): string { + const backgroundColor = this.sanitizeColor(config.backgroundColor || '#000000'); + const borderColor = this.sanitizeColor(config.borderColor || '#000000'); + const borderWidth = config.borderWidth || 0; + + const borderStyle = borderWidth > 0 ? `border: ${borderWidth}px solid ${borderColor};` : 'border: none;'; + return `
    `; + } + + // ----- GitHub Widget ----- + private async generateGitHubContent(config: Record): Promise { + const owner = config.owner || 'facebook'; + const repo = config.repo || 'react'; + const showIcon = config.showIcon !== false; + const showRepoName = config.showRepoName as boolean; + const fontSize = config.fontSize || 32; + + const result = await this.fetchGitHubStars(owner, repo); + const starsCount = result?.stars ?? 0; + const formattedStars = starsCount >= 1000000 ? `${(starsCount / 1000000).toFixed(1)}M` : starsCount >= 1000 ? `${(starsCount / 1000).toFixed(1)}k` : String(starsCount); + const iconSize = Math.min(fontSize * 1.2, 48); + const smallFontSize = Math.max(12, fontSize * 0.4); + + let html = '
    '; + html += '
    '; + if (showIcon) { + html += ``; + } + html += `${formattedStars}`; + html += '
    '; + if (showRepoName) { + html += `
    ${this.escapeHtml(owner)}/${this.escapeHtml(repo)}
    `; + } + html += '
    '; + return html; + } + + private getGitHubStyles(config: Record): string { + const fontFamily = this.mapFontFamily(config.fontFamily || 'sans-serif'); + return `font-family: ${fontFamily}; justify-content: center;`; + } + + // ----- Custom Widget ----- + private async generateCustomWidgetContent(config: Record, width: number, height: number): Promise { + const customWidgetId = config.customWidgetId as number; + if (!customWidgetId) return '
    No widget ID
    '; + + try { + const result = await this.customWidgetsService.getWithData(customWidgetId); + const renderedContent = result.renderedContent; + const widgetConfig = (result.widget?.config as Record) || {}; + + const fontSize = config.fontSize || 24; + const fontFamily = this.mapFontFamily(config.fontFamily || 'sans-serif'); + const fontWeight = config.fontWeight || 'normal'; + const textAlign = config.textAlign || 'center'; + const verticalAlign = config.verticalAlign || 'middle'; + const color = this.sanitizeColor(config.color || '#000000'); + + const alignItems = textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'; + const justifyContent = verticalAlign === 'top' ? 'flex-start' : verticalAlign === 'bottom' ? 'flex-end' : 'center'; + + const baseStyle = `display: flex; flex-direction: column; align-items: ${alignItems}; justify-content: ${justifyContent}; width: 100%; height: 100%; padding: 8px; font-size: ${fontSize}px; font-family: ${fontFamily}; font-weight: ${fontWeight}; text-align: ${textAlign}; color: ${color}; line-height: 1.2; overflow: hidden;`; + + if (typeof renderedContent === 'string') { + if (widgetConfig.fieldType === 'image') { + const imageUrl = await this.processImageForEinkHtml(renderedContent); + return `
    `; + } + return `
    ${this.escapeHtml(renderedContent)}
    `; + } + + if (Array.isArray(renderedContent)) { + const items = renderedContent.slice(0, 10).map(item => + `
  • ${this.escapeHtml(String(item))}
  • ` + ).join(''); + return `
      ${items}
    `; + } + + if (typeof renderedContent === 'object' && renderedContent !== null) { + // Grid display + if ('type' in renderedContent && renderedContent.type === 'grid') { + return await this.generateGridHtml(renderedContent as any, fontSize, fontFamily, config.cellOverrides || {}); + } + // Title-value display + if (('title' in renderedContent || 'label' in renderedContent) && 'value' in renderedContent) { + const label = String('title' in renderedContent ? renderedContent.title : renderedContent.label); + const value = String(renderedContent.value); + if (widgetConfig.valueFieldType === 'image') { + const imageUrl = await this.processImageForEinkHtml(value); + return `
    +
    ${this.escapeHtml(label)}
    + +
    `; + } + return `
    +
    ${this.escapeHtml(label)}
    +
    ${this.escapeHtml(value)}
    +
    `; + } + // Generic object + return `
    ${this.escapeHtml(JSON.stringify(renderedContent, null, 2))}
    `; + } + + return '
    Invalid content
    '; + } catch (error) { + return `
    Error: ${this.escapeHtml(String(error))}
    `; + } + } + + private getCustomWidgetStyles(config: Record): string { + return ''; + } + + private async generateGridHtml( + gridContent: { gridCols: number; gridRows: number; gridGap: number; cells: any[] }, + fontSize: number, + fontFamily: string, + cellOverrides: Record + ): Promise { + const { gridCols, gridRows, gridGap, cells } = gridContent; + const cellFontSize = fontSize * 0.7; + + const cellsHtml = await Promise.all(cells.map(async cell => { + const cellKey = `${cell.row}-${cell.col}`; + const override = cellOverrides[cellKey] || {}; + const cFontSize = override.fontSize || cellFontSize; + const cFontWeight = override.fontWeight || 'bold'; + const cFontFamily = override.fontFamily || fontFamily; + const cAlign = override.align || cell.align || 'center'; + + if (cell.fieldType === 'image' && cell.value) { + const imageUrl = await this.processImageForEinkHtml(String(cell.value)); + return `
    + ${cell.label ? `
    ${this.escapeHtml(cell.label)}
    ` : ''} + +
    `; + } + + return `
    + ${cell.label ? `
    ${this.escapeHtml(cell.label)}
    ` : ''} + ${this.escapeHtml(cell.formattedValue)} +
    `; + })); + + return `
    ${cellsHtml.join('')}
    `; + } + + // 1x1 transparent pixel fallback — prevents Puppeteer from fetching unreachable URLs + private static readonly FALLBACK_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + + private async processImageForEinkHtml(url: string): Promise { + if (!url) return ''; + try { + let imageBuffer: Buffer; + if (url.startsWith('/uploads/')) { + const fs = await import('fs/promises'); + imageBuffer = await fs.readFile(this.resolveUploadPath(url)); + } else if (url.startsWith('data:')) { + return url; // Already a data URL + } else { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + imageBuffer = Buffer.from(await response.arrayBuffer()); + } + // Flatten transparent backgrounds to white before processing + const processedBuffer = await sharp(imageBuffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .grayscale() + .normalize() + .png() + .toBuffer(); + return `data:image/png;base64,${processedBuffer.toString('base64')}`; + } catch (error) { + this.logger.warn(`Failed to process image for e-ink HTML from ${url}: ${error instanceof Error ? error.message : String(error)}`); + return ScreenRendererService.FALLBACK_PIXEL; + } + } +} diff --git a/backend/src/screen-designer/services/widget-templates.service.ts b/backend/src/screen-designer/services/widget-templates.service.ts new file mode 100644 index 0000000..01c635e --- /dev/null +++ b/backend/src/screen-designer/services/widget-templates.service.ts @@ -0,0 +1,433 @@ +import { + Injectable, + NotFoundException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CustomWidgetsService } from '../../custom-widgets/custom-widgets.service'; +import { wrapListResponse } from '../../common/utils/response.util'; + +/** + * Default widget templates to seed + */ +const DEFAULT_WIDGET_TEMPLATES = [ + { + name: 'clock', + label: 'Live Clock', + description: 'Displays current time with configurable timezone', + category: 'time', + defaultConfig: { + timezone: 'local', // Use 'local' for server timezone, or specify like 'Europe/Warsaw', 'America/New_York' + format: '24h', // or '12h' + showSeconds: false, + showDate: true, + dateFormat: 'YYYY-MM-DD', + fontFamily: 'monospace', + fontSize: 48, + }, + minWidth: 200, + minHeight: 80, + }, + { + name: 'weather', + label: 'Weather', + description: 'Shows weather for a location with forecast options', + category: 'weather', + defaultConfig: { + location: 'Warsaw', + latitude: 52.2297, + longitude: 21.0122, + units: 'metric', // 'metric' (Celsius) or 'imperial' (Fahrenheit) + forecastDay: 0, // 0 = today, 1 = tomorrow, 2 = +2 days, etc. (up to 7) + forecastTime: 'current', // 'current', 'morning' (6-9), 'noon' (12), 'afternoon' (15), 'evening' (18-21), 'night' (0-3) + showIcon: true, + showTemperature: true, + showCondition: true, + showLocation: true, // Show location name at bottom + showHumidity: false, + showWind: false, + showDayName: false, // Show "Monday", "Tomorrow", etc. + fontSize: 32, + }, + minWidth: 200, + minHeight: 150, + }, + { + name: 'text', + label: 'Text Block', + description: 'Static text content', + category: 'content', + defaultConfig: { + text: 'Hello World', + fontFamily: 'sans-serif', + fontSize: 24, + fontWeight: 'normal', + textAlign: 'left', + color: '#000000', + }, + minWidth: 100, + minHeight: 40, + }, + { + name: 'date', + label: 'Date Display', + description: 'Shows current date', + category: 'time', + defaultConfig: { + timezone: 'local', // Use 'local' for server timezone, or specify like 'Europe/Warsaw', 'America/New_York' + format: 'dddd, MMMM D, YYYY', + fontSize: 24, + fontFamily: 'sans-serif', + color: '#000000', + }, + minWidth: 250, + minHeight: 50, + }, + { + name: 'qrcode', + label: 'QR Code', + description: 'Generates a QR code from text/URL', + category: 'content', + defaultConfig: { + content: 'https://example.com', + size: 150, + errorCorrection: 'M', // L, M, Q, H + darkColor: '#000000', + lightColor: '#FFFFFF', + }, + minWidth: 100, + minHeight: 100, + }, + { + name: 'battery', + label: 'Battery Status', + description: 'Shows device battery level', + category: 'system', + defaultConfig: { + showPercentage: true, + showIcon: true, + fontSize: 18, + iconSize: 24, + color: '#000000', + }, + minWidth: 80, + minHeight: 40, + }, + { + name: 'countdown', + label: 'Countdown Timer', + description: 'Counts down to a specific date/time', + category: 'time', + defaultConfig: { + targetDate: '2025-01-01T00:00:00Z', + label: 'New Year', + showDays: true, + showHours: true, + showMinutes: true, + showSeconds: false, + fontSize: 32, + fontFamily: 'monospace', + color: '#000000', + }, + minWidth: 250, + minHeight: 80, + }, + { + name: 'image', + label: 'Static Image', + description: 'Displays a static image from URL', + category: 'content', + defaultConfig: { + url: '', + fit: 'contain', // contain, cover, fill + backgroundColor: '#FFFFFF', + }, + minWidth: 50, + minHeight: 50, + }, + { + name: 'divider', + label: 'Divider Line', + description: 'A horizontal or vertical divider line', + category: 'layout', + defaultConfig: { + orientation: 'horizontal', // horizontal, vertical + color: '#000000', + thickness: 2, + style: 'solid', // solid, dashed, dotted + }, + minWidth: 20, + minHeight: 2, + }, + { + name: 'rectangle', + label: 'Rectangle', + description: 'A simple rectangle shape', + category: 'layout', + defaultConfig: { + fillColor: '#000000', + borderColor: '#000000', + borderWidth: 0, + borderRadius: 0, + }, + minWidth: 20, + minHeight: 20, + }, + { + name: 'wifi', + label: 'WiFi Status', + description: 'Shows WiFi signal strength', + category: 'system', + defaultConfig: { + showStrength: true, + showIcon: true, + fontSize: 18, + iconSize: 24, + color: '#000000', + }, + minWidth: 80, + minHeight: 40, + }, + { + name: 'deviceinfo', + label: 'Device Info', + description: 'Shows device name and status', + category: 'system', + defaultConfig: { + showName: true, + showStatus: true, + showLastSeen: false, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000000', + }, + minWidth: 150, + minHeight: 60, + }, + { + name: 'daysuntil', + label: 'Days Until', + description: 'Shows days remaining until an event (e.g., "Days till Christmas: 15")', + category: 'time', + defaultConfig: { + eventName: 'Christmas', + targetDate: '2025-12-25', + fontSize: 32, + fontFamily: 'sans-serif', + color: '#000000', + showLabel: true, + labelPosition: 'before', // 'before' = "Days till Christmas: 15", 'after' = "15 days till Christmas" + }, + minWidth: 200, + minHeight: 60, + }, + { + name: 'github', + label: 'GitHub Stars', + description: 'Displays star count for a GitHub repository', + category: 'content', + defaultConfig: { + owner: 'facebook', + repo: 'react', + showIcon: true, + showRepoName: false, + fontSize: 32, + fontFamily: 'sans-serif', + }, + minWidth: 100, + minHeight: 50, + }, + // Special base template for custom widgets - used as FK reference in database + { + name: 'custom-widget-base', + label: 'Custom Widget', + description: 'Base template for user-created custom widgets', + category: 'custom', + defaultConfig: { + customWidgetId: null, + displayType: 'value', + fontSize: 24, + }, + minWidth: 100, + minHeight: 50, + }, +]; + +// Re-export the constant for convenience +export { CUSTOM_WIDGET_TEMPLATE_OFFSET } from '../../common/constants/widget.constants'; + +@Injectable() +export class WidgetTemplatesService implements OnModuleInit { + private readonly logger = new Logger(WidgetTemplatesService.name); + + constructor( + private prisma: PrismaService, + private customWidgetsService: CustomWidgetsService, + ) {} + + /** + * Initialize widget templates on module init + */ + async onModuleInit() { + // Seed templates on startup if they don't exist + await this.seedTemplates(); + } + + /** + * Get all widget templates (including custom widgets) + */ + async getAll(includeCustom = true) { + const templates = await this.prisma.widgetTemplate.findMany({ + orderBy: [{ category: 'asc' }, { name: 'asc' }], + }); + + // Filter out the base template - it's only for internal use + // Users should create actual custom widgets instead + const filteredTemplates = templates.filter(t => t.name !== 'custom-widget-base'); + + if (!includeCustom) { + return wrapListResponse(filteredTemplates); + } + + // Get custom widgets as templates + const customTemplates = await this.customWidgetsService.getAsWidgetTemplates(); + + // Combine built-in templates with custom widgets + const allTemplates = [...filteredTemplates, ...customTemplates]; + + return wrapListResponse(allTemplates); + } + + /** + * Get widget templates by category + */ + async getByCategory(category: string) { + const templates = await this.prisma.widgetTemplate.findMany({ + where: { category }, + orderBy: { name: 'asc' }, + }); + + return wrapListResponse(templates); + } + + /** + * Get a single widget template by ID + */ + async getById(id: number) { + const template = await this.prisma.widgetTemplate.findUnique({ + where: { id }, + }); + + if (!template) { + throw new NotFoundException('Widget template not found'); + } + + return template; + } + + /** + * Get a single widget template by name + */ + async getByName(name: string) { + const template = await this.prisma.widgetTemplate.findUnique({ + where: { name }, + }); + + if (!template) { + throw new NotFoundException(`Widget template '${name}' not found`); + } + + return template; + } + + /** + * Get all unique categories (including custom if widgets exist) + */ + async getCategories() { + const templates = await this.prisma.widgetTemplate.findMany({ + select: { category: true }, + distinct: ['category'], + orderBy: { category: 'asc' }, + }); + + const categories = templates.map((t) => t.category); + + // Check if there are any custom widgets + const customWidgetCount = await this.prisma.customWidget.count(); + if (customWidgetCount > 0 && !categories.includes('custom')) { + categories.push('custom'); + } + + return categories; + } + + /** + * Get the custom widget base template ID + * This is used when saving screen widgets that reference custom widgets + */ + async getCustomWidgetBaseTemplateId(): Promise { + const template = await this.prisma.widgetTemplate.findUnique({ + where: { name: 'custom-widget-base' }, + select: { id: true }, + }); + + if (!template) { + // Create it if it doesn't exist (shouldn't happen after seeding) + const newTemplate = await this.prisma.widgetTemplate.create({ + data: { + name: 'custom-widget-base', + label: 'Custom Widget', + description: 'Base template for user-created custom widgets', + category: 'custom', + defaultConfig: { + customWidgetId: null, + displayType: 'value', + fontSize: 24, + }, + minWidth: 100, + minHeight: 50, + }, + }); + return newTemplate.id; + } + + return template.id; + } + + /** + * Seed default widget templates + */ + async seedTemplates() { + this.logger.log('Checking widget templates...'); + + let created = 0; + let skipped = 0; + + for (const template of DEFAULT_WIDGET_TEMPLATES) { + const existing = await this.prisma.widgetTemplate.findUnique({ + where: { name: template.name }, + }); + + if (!existing) { + await this.prisma.widgetTemplate.create({ + data: template, + }); + created++; + this.logger.debug(`Created widget template: ${template.name}`); + } else { + skipped++; + } + } + + if (created > 0) { + this.logger.log(`Created ${created} widget templates`); + } + + if (skipped > 0) { + this.logger.debug(`Skipped ${skipped} existing widget templates`); + } + + return { created, skipped }; + } +} diff --git a/backend/src/screens/dto/create-screen.dto.ts b/backend/src/screens/dto/create-screen.dto.ts new file mode 100644 index 0000000..b7bf72d --- /dev/null +++ b/backend/src/screens/dto/create-screen.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsInt, IsBoolean, IsUrl, MaxLength } from 'class-validator'; + +export class CreateScreenDto { + @ApiProperty({ + example: 'Weather Dashboard', + description: 'Screen name', + }) + @IsString() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ + example: 'Displays current weather and forecast', + description: 'Screen description', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiProperty({ + example: 'https://example.com/screens/weather.png', + description: 'Full-size image URL', + }) + @IsString() + @IsUrl() + @MaxLength(2048) + imageUrl: string; + + @ApiPropertyOptional({ + example: 'https://example.com/screens/weather-thumb.png', + description: 'Thumbnail image URL', + }) + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(2048) + thumbnailUrl?: string; + + @ApiProperty({ + example: 1, + description: 'Device model ID', + }) + @IsInt() + modelId: number; + + @ApiPropertyOptional({ + example: false, + description: 'Whether screen is publicly accessible', + default: false, + }) + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} diff --git a/backend/src/screens/dto/update-screen.dto.ts b/backend/src/screens/dto/update-screen.dto.ts new file mode 100644 index 0000000..f143c65 --- /dev/null +++ b/backend/src/screens/dto/update-screen.dto.ts @@ -0,0 +1,58 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsInt, IsBoolean, IsUrl, MaxLength } from 'class-validator'; + +export class UpdateScreenDto { + @ApiPropertyOptional({ + example: 'Weather Dashboard', + description: 'Screen name', + }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ + example: 'Displays current weather and forecast', + description: 'Screen description', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ + example: 'https://example.com/screens/weather.png', + description: 'Full-size image URL', + }) + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(2048) + imageUrl?: string; + + @ApiPropertyOptional({ + example: 'https://example.com/screens/weather-thumb.png', + description: 'Thumbnail image URL', + }) + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(2048) + thumbnailUrl?: string; + + @ApiPropertyOptional({ + example: 1, + description: 'Device model ID', + }) + @IsOptional() + @IsInt() + modelId?: number; + + @ApiPropertyOptional({ + example: true, + description: 'Whether screen is publicly accessible', + }) + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} diff --git a/backend/src/screens/screens.controller.test.ts b/backend/src/screens/screens.controller.test.ts new file mode 100644 index 0000000..465b50e --- /dev/null +++ b/backend/src/screens/screens.controller.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { ScreensController } from './screens.controller'; +import { ScreensService } from './screens.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('ScreensController (e2e)', () => { + let app: INestApplication; + + const mockScreen = { + id: 1, + name: 'Test Screen', + description: 'A test screen', + imageUrl: '/uploads/screens/test.png', + thumbnailUrl: '/uploads/screens/thumb_test.jpg', + isPublic: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const mockScreensService = { + create: async () => mockScreen, + findAll: async () => ({ items: [mockScreen], total: 1 }), + findOne: async () => mockScreen, + update: async () => ({ ...mockScreen, name: 'Updated Screen' }), + remove: async () => ({ message: 'Screen deleted successfully', affectedPlaylists: 0 }), + findPublicScreens: async () => ({ items: [], total: 0 }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [ScreensController], + providers: [ + { provide: ScreensService, useValue: mockScreensService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /screens', () => { + it('should return list of screens', async () => { + const response = await request(app.getHttpServer()) + .get('/screens') + .expect(200); + + expect(response.body.data).toHaveProperty('items'); + }); + }); + + describe('GET /screens/:id', () => { + it('should return a screen by ID', async () => { + const response = await request(app.getHttpServer()) + .get('/screens/1') + .expect(200); + + expect(response.body.data).toHaveProperty('id', 1); + expect(response.body.data).toHaveProperty('name', 'Test Screen'); + }); + }); + + describe('POST /screens', () => { + it('should create a new screen', async () => { + const response = await request(app.getHttpServer()) + .post('/screens') + .send({ name: 'New Screen', imageUrl: 'https://example.com/screen.png', modelId: 1 }) + .expect(201); + + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('name'); + }); + }); + + describe('DELETE /screens/:id', () => { + it('should delete a screen', async () => { + const response = await request(app.getHttpServer()) + .delete('/screens/1') + .expect(200); + + expect(response.body.data).toHaveProperty('message', 'Screen deleted successfully'); + }); + }); +}); diff --git a/backend/src/screens/screens.controller.ts b/backend/src/screens/screens.controller.ts new file mode 100644 index 0000000..fdf56e5 --- /dev/null +++ b/backend/src/screens/screens.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseIntPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { ScreensService } from './screens.service'; +import { CreateScreenDto } from './dto/create-screen.dto'; +import { UpdateScreenDto } from './dto/update-screen.dto'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('screens') +@Controller('screens') +export class ScreensController { + constructor(private readonly screensService: ScreensService) {} + + @Post() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Create a new screen' }) + @ApiResponse({ status: 201, description: 'Screen successfully created' }) + create(@Body() createScreenDto: CreateScreenDto) { + return this.screensService.create(createScreenDto); + } + + @Get() + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get all screens' }) + @ApiResponse({ status: 200, description: 'List of screens' }) + findAll() { + return this.screensService.findAll(); + } + + @Public() + @Get('public') + @ApiOperation({ summary: 'Get public screens' }) + @ApiResponse({ status: 200, description: 'List of public screens' }) + findPublic() { + return this.screensService.findPublicScreens(); + } + + @Get(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get screen by ID' }) + @ApiResponse({ status: 200, description: 'Screen details' }) + @ApiResponse({ status: 404, description: 'Screen not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.screensService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Update screen' }) + @ApiResponse({ status: 200, description: 'Screen successfully updated' }) + @ApiResponse({ status: 404, description: 'Screen not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateScreenDto: UpdateScreenDto, + ) { + return this.screensService.update(id, updateScreenDto); + } + + @Delete(':id') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Delete screen' }) + @ApiResponse({ status: 200, description: 'Screen successfully deleted' }) + @ApiResponse({ status: 404, description: 'Screen not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.screensService.remove(id); + } +} diff --git a/backend/src/screens/screens.module.ts b/backend/src/screens/screens.module.ts new file mode 100644 index 0000000..426577e --- /dev/null +++ b/backend/src/screens/screens.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { ScreensController } from './screens.controller'; +import { ScreensService } from './screens.service'; +import { ImageProcessorService } from './services/image-processor.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ScreenDesignerModule } from '../screen-designer/screen-designer.module'; + +@Module({ + imports: [PrismaModule, ScreenDesignerModule], + controllers: [ScreensController], + providers: [ + ScreensService, + ImageProcessorService, + ], + exports: [ + ScreensService, + ImageProcessorService, + ], +}) +export class ScreensModule {} diff --git a/backend/src/screens/screens.service.test.ts b/backend/src/screens/screens.service.test.ts new file mode 100644 index 0000000..2c7ebbc --- /dev/null +++ b/backend/src/screens/screens.service.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { NotFoundException } from '@nestjs/common'; +import { ScreensService } from './screens.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; +import { createMock, MockFn } from '../test/mocks/helpers'; + +describe('ScreensService', () => { + let service: ScreensService; + let mockPrisma: MockPrisma; + let mockScreenRenderer: { renderHtmlToPng: MockFn; renderUrlToPng: MockFn }; + let mockImageProcessor: { processForEinkWithDithering: MockFn; createThumbnail: MockFn }; + let mockEventsService: { notifyScreenUpdate: MockFn; notifyPlaylistUpdate: MockFn }; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + mockScreenRenderer = { + renderHtmlToPng: createMock().mockResolvedValue(Buffer.from('png')), + renderUrlToPng: createMock().mockResolvedValue(Buffer.from('png')), + }; + mockImageProcessor = { + processForEinkWithDithering: createMock(), + createThumbnail: createMock(), + }; + mockEventsService = { + notifyScreenUpdate: createMock().mockResolvedValue(undefined), + notifyPlaylistUpdate: createMock().mockResolvedValue(undefined), + }; + + service = new ScreensService( + mockPrisma as any, + mockScreenRenderer as any, + mockImageProcessor as any, + mockEventsService as any, + ); + }); + + // ─── create ────────────────────────────────────────────────────────── + + describe('create()', () => { + it('should create a screen and return it', async () => { + const dto = { name: 'Test Screen', description: 'desc', modelId: 1 }; + const created = { id: 1, ...dto }; + mockPrisma.screen.create.mockResolvedValue(created); + + const result = await service.create(dto as any); + expect(result).toEqual(created); + expect(mockPrisma.screen.create.calls).toHaveLength(1); + }); + }); + + // ─── findOne ───────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should return screen when found', async () => { + const screen = { id: 1, name: 'Screen', playlistItems: [] }; + mockPrisma.screen.findUnique.mockResolvedValue(screen); + + const result = await service.findOne(1); + expect(result).toEqual(screen); + }); + + it('should throw NotFoundException when not found', async () => { + mockPrisma.screen.findUnique.mockResolvedValue(null); + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + }); + + // ─── update ────────────────────────────────────────────────────────── + + describe('update()', () => { + it('should throw NotFoundException when screen does not exist', async () => { + mockPrisma.screen.findUnique.mockResolvedValue(null); + await expect(service.update(999, { name: 'New' } as any)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should update screen and notify', async () => { + mockPrisma.screen.findUnique.mockResolvedValue({ id: 1 }); + const updated = { id: 1, name: 'Updated' }; + mockPrisma.screen.update.mockResolvedValue(updated); + + const result = await service.update(1, { name: 'Updated' } as any); + expect(result).toEqual(updated); + expect(mockEventsService.notifyScreenUpdate.calls).toHaveLength(1); + expect(mockEventsService.notifyScreenUpdate.calls[0][0]).toBe(1); + }); + }); + + // ─── remove ────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should throw NotFoundException when screen does not exist', async () => { + mockPrisma.screen.findUnique.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + + it('should delete screen and notify affected playlists', async () => { + mockPrisma.screen.findUnique.mockResolvedValue({ + id: 1, + name: 'Screen', + imageUrl: null, + thumbnailUrl: null, + _count: { playlistItems: 2 }, + playlistItems: [ + { playlistId: 10 }, + { playlistId: 20 }, + { playlistId: 10 }, // duplicate — should be deduplicated + ], + }); + mockPrisma.screen.delete.mockResolvedValue({}); + + const result = await service.remove(1); + expect(result.message).toBe('Screen deleted successfully'); + expect(result.affectedPlaylists).toBe(2); + expect(mockPrisma.screen.delete.calls).toHaveLength(1); + // Should notify each unique playlist + expect(mockEventsService.notifyPlaylistUpdate.calls).toHaveLength(2); + expect(mockEventsService.notifyPlaylistUpdate.calls[0][0]).toBe(10); + expect(mockEventsService.notifyPlaylistUpdate.calls[1][0]).toBe(20); + }); + + it('should handle screen with no playlist items', async () => { + mockPrisma.screen.findUnique.mockResolvedValue({ + id: 2, + name: 'Orphan', + imageUrl: null, + thumbnailUrl: null, + _count: { playlistItems: 0 }, + playlistItems: [], + }); + mockPrisma.screen.delete.mockResolvedValue({}); + + const result = await service.remove(2); + expect(result.affectedPlaylists).toBe(0); + expect(mockEventsService.notifyPlaylistUpdate.calls).toHaveLength(0); + }); + }); +}); diff --git a/backend/src/screens/screens.service.ts b/backend/src/screens/screens.service.ts new file mode 100644 index 0000000..fe93dbf --- /dev/null +++ b/backend/src/screens/screens.service.ts @@ -0,0 +1,491 @@ +import { + Injectable, + NotFoundException, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateScreenDto } from './dto/create-screen.dto'; +import { UpdateScreenDto } from './dto/update-screen.dto'; +import { wrapListResponse } from '../common/utils/response.util'; +import { ImageProcessorService } from './services/image-processor.service'; +import { ScreenRendererService } from '../screen-designer/services/screen-renderer.service'; +import { EventsService } from '../events/events.service'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { generateToken } from '../common/utils/crypto.util'; + +@Injectable() +export class ScreensService { + private readonly logger = new Logger(ScreensService.name); + private readonly uploadsDir = path.join(process.cwd(), 'uploads', 'screens'); + + constructor( + private prisma: PrismaService, + private screenRenderer: ScreenRendererService, + private imageProcessor: ImageProcessorService, + private eventsService: EventsService, + ) { + // Ensure uploads directory exists + this.ensureUploadsDir(); + } + + private async ensureUploadsDir() { + try { + await fs.mkdir(this.uploadsDir, { recursive: true }); + } catch (error) { + this.logger.error('Failed to create uploads directory:', error); + } + } + + /** + * Create a new screen + */ + async create(createScreenDto: CreateScreenDto) { + const screen = await this.prisma.screen.create({ + data: { + name: createScreenDto.name, + description: createScreenDto.description, + imageUrl: createScreenDto.imageUrl, + thumbnailUrl: createScreenDto.thumbnailUrl, + modelId: createScreenDto.modelId, + isPublic: createScreenDto.isPublic || false, + }, + include: { + model: true, + }, + }); + + this.logger.log(`Screen created: ${screen.name}`); + + return screen; + } + + /** + * Find all screens + */ + async findAll() { + const screens = await this.prisma.screen.findMany({ + include: { + model: true, + _count: { + select: { + playlistItems: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return wrapListResponse(screens); + } + + /** + * Find one screen by ID + */ + async findOne(id: number) { + const screen = await this.prisma.screen.findUnique({ + where: { id }, + include: { + model: true, + playlistItems: { + include: { + playlist: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }); + + if (!screen) { + throw new NotFoundException('Screen not found'); + } + + return screen; + } + + /** + * Update screen + */ + async update(id: number, updateScreenDto: UpdateScreenDto) { + const screen = await this.prisma.screen.findUnique({ + where: { id }, + }); + + if (!screen) { + throw new NotFoundException('Screen not found'); + } + + const updatedScreen = await this.prisma.screen.update({ + where: { id }, + data: { + name: updateScreenDto.name, + description: updateScreenDto.description, + imageUrl: updateScreenDto.imageUrl, + thumbnailUrl: updateScreenDto.thumbnailUrl, + modelId: updateScreenDto.modelId, + isPublic: updateScreenDto.isPublic, + }, + include: { + model: true, + }, + }); + + this.logger.log(`Screen updated: ${updatedScreen.name}`); + + // Notify devices that use this screen to refresh + await this.eventsService.notifyScreenUpdate(id); + + return updatedScreen; + } + + /** + * Delete screen + * Handles cascade deletion of associated PlaylistItems (via Prisma onDelete: Cascade) + * Also cleans up uploaded image files from the filesystem + */ + async remove(id: number) { + const screen = await this.prisma.screen.findUnique({ + where: { id }, + include: { + _count: { + select: { + playlistItems: true, + }, + }, + playlistItems: { + select: { + playlistId: true, + }, + }, + }, + }); + + if (!screen) { + throw new NotFoundException('Screen not found'); + } + + // Collect affected playlist IDs for notification before deletion + const affectedPlaylistIds = [ + ...new Set(screen.playlistItems.map((item) => item.playlistId)), + ]; + + // Delete screen from database (PlaylistItems cascade automatically via Prisma) + await this.prisma.screen.delete({ + where: { id }, + }); + + // Clean up image files from filesystem + await this.cleanupScreenFiles(screen.imageUrl, screen.thumbnailUrl); + + this.logger.log( + `Screen deleted: ${screen.name} (removed from ${screen._count.playlistItems} playlist(s))`, + ); + + // Notify devices using affected playlists to refresh + for (const playlistId of affectedPlaylistIds) { + await this.eventsService.notifyPlaylistUpdate(playlistId); + } + + return { + message: 'Screen deleted successfully', + affectedPlaylists: affectedPlaylistIds.length, + }; + } + + /** + * Clean up screen image files from filesystem + * Silently handles missing files (already deleted or never existed) + */ + private async cleanupScreenFiles( + imageUrl: string | null, + thumbnailUrl: string | null, + ): Promise { + const filesToDelete: string[] = []; + + // Convert URL paths to filesystem paths + if (imageUrl) { + // imageUrl format: /uploads/screens/processed_filename.png + const imagePath = path.join(process.cwd(), imageUrl); + filesToDelete.push(imagePath); + } + + if (thumbnailUrl) { + // thumbnailUrl format: /uploads/screens/thumb_filename.jpg + const thumbPath = path.join(process.cwd(), thumbnailUrl); + filesToDelete.push(thumbPath); + } + + // Delete files concurrently, ignoring errors for missing files + await Promise.allSettled( + filesToDelete.map(async (filePath) => { + try { + await fs.unlink(filePath); + this.logger.debug(`Deleted file: ${filePath}`); + } catch (error: any) { + // ENOENT = file not found, which is acceptable + if (error.code !== 'ENOENT') { + this.logger.warn(`Failed to delete file ${filePath}: ${error.message}`); + } + } + }), + ); + } + + /** + * Get public screens (for device/public access) + */ + async findPublicScreens() { + const screens = await this.prisma.screen.findMany({ + where: { isPublic: true }, + include: { + model: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return wrapListResponse(screens); + } + + /** + * Create screen from HTML content + * Generates PNG screenshot using Puppeteer + */ + async createFromHtml( + html: string, + name: string, + modelId: number, + description?: string, + ) { + // Get model dimensions + const model = await this.prisma.model.findUnique({ + where: { id: modelId }, + }); + + if (!model) { + throw new BadRequestException('Model not found'); + } + + // Generate unique filename + const filename = `screen_${Date.now()}_${generateToken(8)}.png`; + const outputPath = path.join(this.uploadsDir, filename); + + // Generate screenshot from HTML using shared Puppeteer browser + const screenshotBuffer = await this.screenRenderer.renderHtmlToPng(html, model.width, model.height); + await fs.writeFile(outputPath, screenshotBuffer); + + // Process image for e-ink display with Floyd-Steinberg dithering + const processedPath = path.join( + this.uploadsDir, + `processed_${filename}`, + ); + await this.imageProcessor.processForEinkWithDithering( + outputPath, + processedPath, + model.width, + model.height, + { dithering: model.colors === 2 }, + ); + + // Create thumbnail + const thumbnailPath = path.join( + this.uploadsDir, + `thumb_${filename}`, + ); + await this.imageProcessor.createThumbnail( + processedPath, + thumbnailPath, + 200, + 150, + ); + + // Clean up original unprocessed file + await fs.unlink(outputPath); + + // Create screen record + const imageUrl = `/uploads/screens/processed_${filename}`; + const thumbnailUrl = `/uploads/screens/thumb_${filename}`; + + const screen = await this.prisma.screen.create({ + data: { + name, + description, + imageUrl, + thumbnailUrl, + modelId, + isPublic: false, + }, + include: { + model: true, + }, + }); + + this.logger.log(`Screen created from HTML: ${screen.name}`); + + return screen; + } + + /** + * Create screen from image file + * Processes and optimizes image for e-ink display + */ + async createFromImage( + imageBuffer: Buffer, + originalName: string, + name: string, + modelId: number, + description?: string, + ) { + // Get model dimensions + const model = await this.prisma.model.findUnique({ + where: { id: modelId }, + }); + + if (!model) { + throw new BadRequestException('Model not found'); + } + + // Generate unique filename + const ext = path.extname(originalName); + const filename = `screen_${Date.now()}_${generateToken(8)}${ext}`; + const tempPath = path.join(this.uploadsDir, `temp_${filename}`); + + // Save temporary file + await fs.writeFile(tempPath, imageBuffer); + + // Process image for e-ink display with Floyd-Steinberg dithering + // Dithering improves grayscale rendering on monochrome e-ink displays + const processedFilename = `processed_${filename.replace(ext, '.png')}`; + const processedPath = path.join(this.uploadsDir, processedFilename); + + await this.imageProcessor.processForEinkWithDithering( + tempPath, + processedPath, + model.width, + model.height, + { dithering: model.colors === 2 }, + ); + + // Create thumbnail + const thumbnailFilename = `thumb_${filename.replace(ext, '.jpg')}`; + const thumbnailPath = path.join(this.uploadsDir, thumbnailFilename); + + await this.imageProcessor.createThumbnail( + processedPath, + thumbnailPath, + 200, + 150, + ); + + // Clean up temporary file + await fs.unlink(tempPath); + + // Create screen record + const imageUrl = `/uploads/screens/${processedFilename}`; + const thumbnailUrl = `/uploads/screens/${thumbnailFilename}`; + + const screen = await this.prisma.screen.create({ + data: { + name, + description, + imageUrl, + thumbnailUrl, + modelId, + isPublic: false, + }, + include: { + model: true, + }, + }); + + this.logger.log(`Screen created from image: ${screen.name}`); + + return screen; + } + + /** + * Create screen from URL + * Takes screenshot of webpage using Puppeteer + */ + async createFromUrl( + url: string, + name: string, + modelId: number, + description?: string, + ) { + // Get model dimensions + const model = await this.prisma.model.findUnique({ + where: { id: modelId }, + }); + + if (!model) { + throw new BadRequestException('Model not found'); + } + + // Generate unique filename + const filename = `screen_${Date.now()}_${generateToken(8)}.png`; + const outputPath = path.join(this.uploadsDir, filename); + + // Generate screenshot from URL using shared Puppeteer browser + const screenshotBuffer = await this.screenRenderer.renderUrlToPng(url, model.width, model.height); + await fs.writeFile(outputPath, screenshotBuffer); + + // Process image for e-ink display with Floyd-Steinberg dithering + const processedPath = path.join( + this.uploadsDir, + `processed_${filename}`, + ); + await this.imageProcessor.processForEinkWithDithering( + outputPath, + processedPath, + model.width, + model.height, + { dithering: model.colors === 2 }, + ); + + // Create thumbnail + const thumbnailPath = path.join( + this.uploadsDir, + `thumb_${filename}`, + ); + await this.imageProcessor.createThumbnail( + processedPath, + thumbnailPath, + 200, + 150, + ); + + // Clean up original unprocessed file + await fs.unlink(outputPath); + + // Create screen record + const imageUrl = `/uploads/screens/processed_${filename}`; + const thumbnailUrl = `/uploads/screens/thumb_${filename}`; + + const screen = await this.prisma.screen.create({ + data: { + name, + description, + imageUrl, + thumbnailUrl, + modelId, + isPublic: false, + }, + include: { + model: true, + }, + }); + + this.logger.log(`Screen created from URL: ${screen.name}`); + + return screen; + } +} diff --git a/backend/src/screens/services/image-processor.service.ts b/backend/src/screens/services/image-processor.service.ts new file mode 100644 index 0000000..6489a89 --- /dev/null +++ b/backend/src/screens/services/image-processor.service.ts @@ -0,0 +1,398 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as sharpModule from 'sharp'; +// Handle both ESM and CJS imports for Bun compatibility +const sharp = (sharpModule as any).default || sharpModule; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Image Processor Service + * Uses Sharp for high-performance image processing + * Handles resize, compress, convert, and optimize images for e-ink displays + */ +@Injectable() +export class ImageProcessorService { + private readonly logger = new Logger(ImageProcessorService.name); + + /** + * Process image for e-ink display + * - Resize to target dimensions + * - Convert to appropriate format + * - Optimize for e-ink (reduce colors, increase contrast) + */ + async processForEink( + inputPath: string, + outputPath: string, + width: number, + height: number, + colors: number = 2, + ): Promise { + try { + this.logger.debug( + `Processing image for e-ink: ${width}x${height}, ${colors} colors`, + ); + + let pipeline = sharp(inputPath) + .resize(width, height, { + fit: 'contain', + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }); + + // Apply grayscale for monochrome displays + if (colors === 2) { + pipeline = pipeline.grayscale(); + } + + // Output as standard grayscale PNG (not palette-indexed) + // This avoids palette ordering issues that can cause color inversion on some e-ink displays + // Grayscale PNG uses 0=black, 255=white which is universally interpreted correctly + pipeline = pipeline.png({ + compressionLevel: 9, + }); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + await pipeline.toFile(outputPath); + + this.logger.debug(`Processed image saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to process image for e-ink:', error); + throw error; + } + } + + /** + * Create thumbnail from image + */ + async createThumbnail( + inputPath: string, + outputPath: string, + width: number = 200, + height: number = 150, + ): Promise { + try { + this.logger.debug(`Creating thumbnail: ${width}x${height}`); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + await sharp(inputPath) + .resize(width, height, { + fit: 'cover', + position: 'center', + }) + .jpeg({ + quality: 80, + }) + .toFile(outputPath); + + this.logger.debug(`Thumbnail saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to create thumbnail:', error); + throw error; + } + } + + /** + * Convert image to PNG format + */ + async convertToPng(inputPath: string, outputPath: string): Promise { + try { + this.logger.debug(`Converting image to PNG: ${inputPath}`); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + await sharp(inputPath) + .png({ + compressionLevel: 9, + }) + .toFile(outputPath); + + this.logger.debug(`PNG image saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to convert to PNG:', error); + throw error; + } + } + + /** + * Get image metadata + */ + async getMetadata(imagePath: string) { + try { + const metadata = await sharp(imagePath).metadata(); + return { + width: metadata.width, + height: metadata.height, + format: metadata.format, + channels: metadata.channels, + hasAlpha: metadata.hasAlpha, + space: metadata.space, + }; + } catch (error) { + this.logger.error('Failed to get image metadata:', error); + throw error; + } + } + + /** + * Compress image + */ + async compress( + inputPath: string, + outputPath: string, + quality: number = 80, + ): Promise { + try { + this.logger.debug(`Compressing image, quality: ${quality}`); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + const extension = path.extname(inputPath).toLowerCase(); + + let pipeline = sharp(inputPath); + + switch (extension) { + case '.jpg': + case '.jpeg': + pipeline = pipeline.jpeg({ quality }); + break; + case '.png': + pipeline = pipeline.png({ + compressionLevel: Math.floor((100 - quality) / 10), + }); + break; + case '.webp': + pipeline = pipeline.webp({ quality }); + break; + default: + pipeline = pipeline.png({ compressionLevel: 9 }); + } + + await pipeline.toFile(outputPath); + + this.logger.debug(`Compressed image saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to compress image:', error); + throw error; + } + } + + /** + * Convert image to base64 + */ + async toBase64(imagePath: string): Promise { + try { + const buffer = await sharp(imagePath).png().toBuffer(); + return buffer.toString('base64'); + } catch (error) { + this.logger.error('Failed to convert image to base64:', error); + throw error; + } + } + + /** + * Apply Floyd-Steinberg dithering for better e-ink rendering + * This algorithm diffuses quantization error to neighboring pixels, + * creating the illusion of more gray levels on binary displays. + * + * The error distribution pattern: + * X 7/16 + * 3/16 5/16 1/16 + * + * This is the classic Floyd-Steinberg pattern for high-quality dithering. + */ + async applyDithering( + inputPath: string, + outputPath: string, + threshold: number = 128, + ): Promise { + try { + this.logger.debug('Applying Floyd-Steinberg dithering to image'); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + // Get image as raw grayscale pixels + const image = sharp(inputPath).grayscale().normalise(); + const metadata = await image.metadata(); + const width = metadata.width!; + const height = metadata.height!; + + // Get raw pixel data (1 byte per pixel for grayscale) + const { data, info } = await image + .raw() + .toBuffer({ resolveWithObject: true }); + + // Create a writable copy of pixel data + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + pixels[i] = data[i]; + } + + // Apply Floyd-Steinberg dithering + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const oldPixel = pixels[idx]; + + // Quantize to black or white + const newPixel = oldPixel < threshold ? 0 : 255; + pixels[idx] = newPixel; + + // Calculate quantization error + const error = oldPixel - newPixel; + + // Distribute error to neighboring pixels (Floyd-Steinberg pattern) + // Right pixel: 7/16 + if (x + 1 < width) { + pixels[idx + 1] += (error * 7) / 16; + } + // Bottom-left pixel: 3/16 + if (x - 1 >= 0 && y + 1 < height) { + pixels[(y + 1) * width + (x - 1)] += (error * 3) / 16; + } + // Bottom pixel: 5/16 + if (y + 1 < height) { + pixels[(y + 1) * width + x] += (error * 5) / 16; + } + // Bottom-right pixel: 1/16 + if (x + 1 < width && y + 1 < height) { + pixels[(y + 1) * width + (x + 1)] += (error * 1) / 16; + } + } + } + + // Convert back to Uint8Array, clamping values + const output = Buffer.alloc(data.length); + for (let i = 0; i < pixels.length; i++) { + output[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + + // Create output image from processed pixels + await sharp(output, { + raw: { + width: info.width, + height: info.height, + channels: 1, + }, + }) + .png({ compressionLevel: 9 }) + .toFile(outputPath); + + this.logger.debug(`Dithered image saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to apply dithering:', error); + throw error; + } + } + + /** + * Process image for e-ink with optional dithering + * Enhanced version that applies Floyd-Steinberg dithering for better results + */ + async processForEinkWithDithering( + inputPath: string, + outputPath: string, + width: number, + height: number, + options: { + dithering?: boolean; + threshold?: number; + contrast?: number; + } = {}, + ): Promise { + try { + const { dithering = true, threshold = 128, contrast = 1.2 } = options; + + this.logger.debug( + `Processing image for e-ink with dithering: ${width}x${height}`, + ); + + // First resize and prepare the image + const tempPath = outputPath.replace('.png', '_temp.png'); + + let pipeline = sharp(inputPath) + .resize(width, height, { + fit: 'contain', + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }) + .grayscale(); + + // Apply contrast enhancement if specified + if (contrast !== 1) { + pipeline = pipeline.linear(contrast, -(128 * contrast - 128)); + } + + // Normalize for better tonal range + pipeline = pipeline.normalise(); + + await pipeline.png({ compressionLevel: 9 }).toFile(tempPath); + + if (dithering) { + // Apply Floyd-Steinberg dithering + await this.applyDithering(tempPath, outputPath, threshold); + // Clean up temp file + await fs.unlink(tempPath).catch(() => {}); + } else { + // Just rename temp to final + await fs.rename(tempPath, outputPath); + } + + this.logger.debug(`Processed e-ink image saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to process image for e-ink:', error); + throw error; + } + } + + /** + * Rotate image + */ + async rotate( + inputPath: string, + outputPath: string, + degrees: number, + ): Promise { + try { + this.logger.debug(`Rotating image: ${degrees} degrees`); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + await sharp(inputPath) + .rotate(degrees, { + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }) + .toFile(outputPath); + + this.logger.debug(`Rotated image saved to: ${outputPath}`); + + return outputPath; + } catch (error) { + this.logger.error('Failed to rotate image:', error); + throw error; + } + } +} diff --git a/backend/src/screens/services/welcome-screen.service.ts b/backend/src/screens/services/welcome-screen.service.ts new file mode 100644 index 0000000..f1d86ae --- /dev/null +++ b/backend/src/screens/services/welcome-screen.service.ts @@ -0,0 +1,190 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ImageProcessorService } from './image-processor.service'; +import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { generateToken } from '../../common/utils/crypto.util'; + +/** + * Welcome Screen Service + * Generates welcome screens for newly provisioned devices + * Similar to Ruby's app/aspects/screens/welcomer.rb + */ +@Injectable() +export class WelcomeScreenService { + private readonly logger = new Logger(WelcomeScreenService.name); + private readonly uploadsDir = path.join(process.cwd(), 'uploads', 'screens'); + + constructor( + private screenRenderer: ScreenRendererService, + private imageProcessor: ImageProcessorService, + ) { + this.ensureUploadsDir(); + } + + private async ensureUploadsDir() { + try { + await fs.mkdir(this.uploadsDir, { recursive: true }); + } catch (error) { + this.logger.error('Failed to create uploads directory:', error); + } + } + + /** + * Generate welcome screen HTML + * Creates a simple, clean design optimized for e-ink display + */ + private generateWelcomeHtml( + deviceName: string, + friendlyId: string, + width: number, + height: number, + ): string { + return ` + + + + + + + +
    Hello World
    +
    This is inker!
    +
    Device: ${deviceName}
    +
    ID: ${friendlyId}
    + + + + `.trim(); + } + + /** + * Create welcome screen for a device + * Generates HTML, renders to PNG, processes for e-ink, creates thumbnail + * + * @param deviceName Device name + * @param friendlyId Device friendly ID + * @param width Screen width in pixels + * @param height Screen height in pixels + * @param colors Number of colors (for e-ink processing, typically 2) + * @returns Object with image and thumbnail paths + */ + async createWelcomeScreen( + deviceName: string, + friendlyId: string, + width: number, + height: number, + colors: number = 2, + ): Promise<{ imageUrl: string; thumbnailUrl: string; imagePath: string }> { + this.logger.log( + `Generating welcome screen for device ${deviceName} (${width}x${height})`, + ); + + try { + // Generate HTML content + const html = this.generateWelcomeHtml( + deviceName, + friendlyId, + width, + height, + ); + + // Generate unique filename + const timestamp = Date.now(); + const token = generateToken(8); + const filename = `welcome_${timestamp}_${token}.png`; + const rawPath = path.join(this.uploadsDir, `raw_${filename}`); + + // Generate screenshot from HTML using shared Puppeteer browser + const screenshotBuffer = await this.screenRenderer.renderHtmlToPng(html, width, height); + await fs.writeFile(rawPath, screenshotBuffer); + + // Process image for e-ink display with Floyd-Steinberg dithering + const processedFilename = `processed_${filename}`; + const processedPath = path.join(this.uploadsDir, processedFilename); + + await this.imageProcessor.processForEinkWithDithering( + rawPath, + processedPath, + width, + height, + { dithering: colors === 2 }, + ); + + // Create thumbnail + const thumbnailFilename = `thumb_${filename.replace('.png', '.jpg')}`; + const thumbnailPath = path.join(this.uploadsDir, thumbnailFilename); + + await this.imageProcessor.createThumbnail( + processedPath, + thumbnailPath, + 200, + 150, + ); + + // Clean up raw file + await fs.unlink(rawPath).catch(() => { + // Ignore errors if file doesn't exist + }); + + const imageUrl = `/uploads/screens/${processedFilename}`; + const thumbnailUrl = `/uploads/screens/${thumbnailFilename}`; + + this.logger.log(`Welcome screen created: ${imageUrl}`); + + return { + imageUrl, + thumbnailUrl, + imagePath: processedPath, + }; + } catch (error) { + this.logger.error('Failed to create welcome screen:', error); + throw error; + } + } +} diff --git a/backend/src/settings/dto/test-github-token.dto.ts b/backend/src/settings/dto/test-github-token.dto.ts new file mode 100644 index 0000000..d428b33 --- /dev/null +++ b/backend/src/settings/dto/test-github-token.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength } from 'class-validator'; + +export class TestGitHubTokenDto { + @ApiProperty({ example: 'ghp_xxxxxxxxxxxx', description: 'GitHub personal access token' }) + @IsString() + @MaxLength(500) + token: string; +} diff --git a/backend/src/settings/dto/update-setting.dto.ts b/backend/src/settings/dto/update-setting.dto.ts new file mode 100644 index 0000000..15f9eaf --- /dev/null +++ b/backend/src/settings/dto/update-setting.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength } from 'class-validator'; + +export class UpdateSettingDto { + @ApiProperty({ example: 'setting-value', description: 'Setting value' }) + @IsString() + @MaxLength(500) + value: string; +} diff --git a/backend/src/settings/settings.controller.test.ts b/backend/src/settings/settings.controller.test.ts new file mode 100644 index 0000000..75efae6 --- /dev/null +++ b/backend/src/settings/settings.controller.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { SettingsController } from './settings.controller'; +import { SettingsService } from './settings.service'; +import { PinAuthGuard } from '../auth/guards/pin-auth.guard'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; + +describe('SettingsController (e2e)', () => { + let app: INestApplication; + + const mockSettingsService = { + getAll: async () => ({ github_token: null }), + set: async () => undefined, + delete: async () => undefined, + testGitHubToken: async () => ({ valid: true, message: 'Token valid!' }), + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [SettingsController], + providers: [ + { provide: SettingsService, useValue: mockSettingsService }, + ], + }) + .overrideGuard(PinAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.useGlobalInterceptors(new TransformInterceptor()); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('GET /settings', () => { + it('should return all settings', async () => { + const response = await request(app.getHttpServer()) + .get('/settings') + .expect(200); + + expect(response.body.data).toHaveProperty('github_token'); + }); + }); + + describe('PUT /settings/:key', () => { + it('should update a setting', async () => { + const response = await request(app.getHttpServer()) + .put('/settings/github_token') + .send({ value: 'ghp_test123' }) + .expect(200); + + expect(response.body.data).toHaveProperty('success', true); + }); + }); + + describe('DELETE /settings/:key', () => { + it('should delete a setting', async () => { + const response = await request(app.getHttpServer()) + .delete('/settings/github_token') + .expect(200); + + expect(response.body.data).toHaveProperty('success', true); + }); + }); + + describe('POST /settings/test-github-token', () => { + it('should test a GitHub token', async () => { + const response = await request(app.getHttpServer()) + .post('/settings/test-github-token') + .send({ token: 'ghp_test123' }) + .expect(201); + + expect(response.body.data).toHaveProperty('valid', true); + }); + }); +}); diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts new file mode 100644 index 0000000..b6a227e --- /dev/null +++ b/backend/src/settings/settings.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Put, Post, Delete, Param, Body, BadRequestException } from '@nestjs/common'; +import { SettingsService, SETTING_KEYS } from './settings.service'; +import { UpdateSettingDto } from './dto/update-setting.dto'; +import { TestGitHubTokenDto } from './dto/test-github-token.dto'; + +@Controller('settings') +export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + /** + * Test a GitHub token before saving + */ + @Post('test-github-token') + async testGitHubToken(@Body() body: TestGitHubTokenDto) { + return this.settingsService.testGitHubToken(body.token); + } + + /** + * Get all settings (with sensitive values masked) + */ + @Get() + async getAll() { + return this.settingsService.getAll(); + } + + /** + * Get known setting keys + */ + @Get('keys') + getKeys() { + return SETTING_KEYS; + } + + /** + * Update a setting + */ + @Put(':key') + async update(@Param('key') key: string, @Body() body: UpdateSettingDto) { + const allowedKeys = Object.values(SETTING_KEYS); + if (!allowedKeys.includes(key as any)) { + throw new BadRequestException(`Invalid setting key: ${key}. Allowed keys: ${allowedKeys.join(', ')}`); + } + await this.settingsService.set(key, body.value); + return { success: true }; + } + + /** + * Delete a setting + */ + @Delete(':key') + async delete(@Param('key') key: string) { + const allowedKeys = Object.values(SETTING_KEYS); + if (!allowedKeys.includes(key as any)) { + throw new BadRequestException(`Invalid setting key: ${key}. Allowed keys: ${allowedKeys.join(', ')}`); + } + await this.settingsService.delete(key); + return { success: true }; + } +} diff --git a/backend/src/settings/settings.module.ts b/backend/src/settings/settings.module.ts new file mode 100644 index 0000000..b6a6fec --- /dev/null +++ b/backend/src/settings/settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SettingsController } from './settings.controller'; +import { SettingsService } from './settings.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [SettingsController], + providers: [SettingsService], + exports: [SettingsService], +}) +export class SettingsModule {} diff --git a/backend/src/settings/settings.service.test.ts b/backend/src/settings/settings.service.test.ts new file mode 100644 index 0000000..cff3678 --- /dev/null +++ b/backend/src/settings/settings.service.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { SettingsService, SETTING_KEYS } from './settings.service'; +import { createMockPrisma, MockPrisma } from '../test/mocks/prisma.mock'; + +describe('SettingsService', () => { + let service: SettingsService; + let mockPrisma: MockPrisma; + + beforeEach(() => { + mockPrisma = createMockPrisma(); + service = new SettingsService(mockPrisma as any); + }); + + // ─── get ──────────────────────────────────────────────────────────── + + describe('get()', () => { + it('should return value when setting is found', async () => { + mockPrisma.setting.findUnique.mockResolvedValue({ + key: 'github_token', + value: 'ghp_test123', + }); + + const result = await service.get('github_token'); + + expect(result).toBe('ghp_test123'); + expect(mockPrisma.setting.findUnique.calls[0][0].where.key).toBe('github_token'); + }); + + it('should return null when setting is not found', async () => { + mockPrisma.setting.findUnique.mockResolvedValue(null); + + const result = await service.get('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + // ─── set ──────────────────────────────────────────────────────────── + + describe('set()', () => { + it('should call prisma.setting.upsert with correct params', async () => { + mockPrisma.setting.upsert.mockResolvedValue({}); + + await service.set('github_token', 'ghp_new_token'); + + expect(mockPrisma.setting.upsert.calls).toHaveLength(1); + const call = mockPrisma.setting.upsert.calls[0][0]; + expect(call.where.key).toBe('github_token'); + expect(call.update.value).toBe('ghp_new_token'); + expect(call.create.key).toBe('github_token'); + expect(call.create.value).toBe('ghp_new_token'); + }); + }); + + // ─── delete ───────────────────────────────────────────────────────── + + describe('delete()', () => { + it('should call prisma.setting.deleteMany with correct key', async () => { + mockPrisma.setting.deleteMany.mockResolvedValue({ count: 1 }); + + await service.delete('github_token'); + + expect(mockPrisma.setting.deleteMany.calls).toHaveLength(1); + expect(mockPrisma.setting.deleteMany.calls[0][0].where.key).toBe('github_token'); + }); + }); + + // ─── getAll ───────────────────────────────────────────────────────── + + describe('getAll()', () => { + it('should initialize all known keys with null', async () => { + mockPrisma.setting.findMany.mockResolvedValue([]); + + const result = await service.getAll(); + + for (const key of Object.values(SETTING_KEYS)) { + expect(result).toHaveProperty(key); + expect(result[key]).toBeNull(); + } + }); + + it('should mask sensitive values (github_token)', async () => { + mockPrisma.setting.findMany.mockResolvedValue([ + { key: 'github_token', value: 'ghp_supersecrettoken123' }, + ]); + + const result = await service.getAll(); + + expect(result.github_token).toBe('••••••••'); + }); + + it('should return null for sensitive key with no value', async () => { + mockPrisma.setting.findMany.mockResolvedValue([ + { key: 'github_token', value: '' }, + ]); + + const result = await service.getAll(); + + expect(result.github_token).toBeNull(); + }); + + it('should show non-sensitive values as-is', async () => { + mockPrisma.setting.findMany.mockResolvedValue([ + { key: 'some_setting', value: 'visible-value' }, + ]); + + const result = await service.getAll(); + + expect(result.some_setting).toBe('visible-value'); + }); + }); + + // ─── isSensitive (private) ───────────────────────────────────────── + + describe('isSensitive()', () => { + const isSensitive = (key: string) => (service as any).isSensitive(key); + + it('should detect "token" in key name', () => { + expect(isSensitive('github_token')).toBe(true); + }); + + it('should detect "key" in key name', () => { + expect(isSensitive('api_key')).toBe(true); + }); + + it('should detect "secret" in key name', () => { + expect(isSensitive('client_secret')).toBe(true); + }); + + it('should detect "password" in key name', () => { + expect(isSensitive('db_password')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(isSensitive('GitHub_TOKEN')).toBe(true); + expect(isSensitive('API_KEY')).toBe(true); + }); + + it('should return false for non-sensitive keys', () => { + expect(isSensitive('theme')).toBe(false); + expect(isSensitive('language')).toBe(false); + expect(isSensitive('refresh_interval')).toBe(false); + }); + }); + + // ─── getGitHubToken ──────────────────────────────────────────────── + + describe('getGitHubToken()', () => { + it('should delegate to get() with github_token key', async () => { + mockPrisma.setting.findUnique.mockResolvedValue({ + key: 'github_token', + value: 'ghp_abc', + }); + + const result = await service.getGitHubToken(); + + expect(result).toBe('ghp_abc'); + expect(mockPrisma.setting.findUnique.calls[0][0].where.key).toBe('github_token'); + }); + }); +}); diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts new file mode 100644 index 0000000..41f1dff --- /dev/null +++ b/backend/src/settings/settings.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +// Known setting keys +export const SETTING_KEYS = { + GITHUB_TOKEN: 'github_token', +} as const; + +export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; + +@Injectable() +export class SettingsService { + private readonly logger = new Logger(SettingsService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Get a setting value by key + */ + async get(key: string): Promise { + const setting = await this.prisma.setting.findUnique({ + where: { key }, + }); + return setting?.value ?? null; + } + + /** + * Set a setting value (create or update) + */ + async set(key: string, value: string): Promise { + this.logger.log(`Setting ${key}`); + await this.prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + } + + /** + * Delete a setting + */ + async delete(key: string): Promise { + this.logger.log(`Deleting setting ${key}`); + await this.prisma.setting.deleteMany({ + where: { key }, + }); + } + + /** + * Get all settings (with masked sensitive values) + */ + async getAll(): Promise> { + const settings = await this.prisma.setting.findMany(); + const result: Record = {}; + + // Initialize all known keys with null + for (const key of Object.values(SETTING_KEYS)) { + result[key] = null; + } + + // Fill in actual values (masked for sensitive ones) + for (const setting of settings) { + if (this.isSensitive(setting.key)) { + // Return masked value to indicate it's set + result[setting.key] = setting.value ? '••••••••' : null; + } else { + result[setting.key] = setting.value; + } + } + + return result; + } + + /** + * Check if a setting key contains sensitive data + */ + private isSensitive(key: string): boolean { + const sensitiveKeys = ['token', 'key', 'secret', 'password']; + return sensitiveKeys.some((s) => key.toLowerCase().includes(s)); + } + + /** + * Get GitHub token (convenience method) + */ + async getGitHubToken(): Promise { + return this.get(SETTING_KEYS.GITHUB_TOKEN); + } + + /** + * Test a GitHub token by calling the API + * Returns rate limit info and validation status + */ + async testGitHubToken(token: string): Promise<{ + valid: boolean; + message: string; + rateLimit?: number; + rateLimitRemaining?: number; + username?: string; + }> { + if (!token || !token.trim()) { + return { valid: false, message: 'Token is empty' }; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + // Test token by calling the user endpoint (most reliable way) + const response = await fetch('https://api.github.com/user', { + signal: controller.signal, + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Inker-E-Ink-Display', + 'Authorization': `Bearer ${token.trim()}`, + }, + }); + clearTimeout(timeoutId); + + const rateLimit = parseInt(response.headers.get('x-ratelimit-limit') || '0', 10); + const rateLimitRemaining = parseInt(response.headers.get('x-ratelimit-remaining') || '0', 10); + + if (response.status === 200) { + const data = await response.json(); + return { + valid: true, + message: `Token valid! Authenticated as ${data.login}`, + rateLimit, + rateLimitRemaining, + username: data.login, + }; + } else if (response.status === 401) { + return { + valid: false, + message: 'Invalid token: Authentication failed', + }; + } else if (response.status === 403) { + return { + valid: false, + message: 'Token rejected: Rate limited or forbidden', + rateLimit, + rateLimitRemaining, + }; + } else { + return { + valid: false, + message: `GitHub API error: ${response.status} ${response.statusText}`, + }; + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { valid: false, message: 'Request timed out' }; + } + this.logger.warn(`GitHub token test failed: ${error instanceof Error ? error.message : String(error)}`); + return { + valid: false, + message: `Connection error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } +} diff --git a/backend/src/test/mocks/helpers.ts b/backend/src/test/mocks/helpers.ts new file mode 100644 index 0000000..bd1386b --- /dev/null +++ b/backend/src/test/mocks/helpers.ts @@ -0,0 +1,71 @@ +/** + * Test helper utilities for bun:test + */ + +export interface MockFn { + (...args: any[]): T; + calls: any[][]; + mockReturnValue(val: T): MockFn; + mockResolvedValue(val: any): MockFn>; + mockRejectedValue(val: any): MockFn>; + mockImplementation(fn: (...args: any[]) => T): MockFn; + mockReset(): void; +} + +/** + * Creates a trackable mock function (replacement for jest.fn()) + */ +export function createMock(defaultReturn?: T): MockFn { + let impl: ((...args: any[]) => T) | null = null; + let returnValue: T = defaultReturn as T; + + const fn = ((...args: any[]) => { + fn.calls.push(args); + if (impl) return impl(...args); + return returnValue; + }) as MockFn; + + fn.calls = []; + + fn.mockReturnValue = (val: T) => { + returnValue = val; + impl = null; + return fn; + }; + + fn.mockResolvedValue = (val: any) => { + returnValue = Promise.resolve(val) as any; + impl = null; + return fn as any; + }; + + fn.mockRejectedValue = (val: any) => { + returnValue = Promise.reject(val) as any; + impl = null; + return fn as any; + }; + + fn.mockImplementation = (newImpl: (...args: any[]) => T) => { + impl = newImpl; + return fn; + }; + + fn.mockReset = () => { + fn.calls = []; + impl = null; + returnValue = defaultReturn as T; + }; + + return fn; +} + +/** + * Creates a mock object where every property is a mock function + */ +export function createMockObject(methods: (keyof T)[]): { [K in keyof T]: MockFn } { + const obj = {} as any; + for (const method of methods) { + obj[method] = createMock(); + } + return obj; +} diff --git a/backend/src/test/mocks/prisma.mock.ts b/backend/src/test/mocks/prisma.mock.ts new file mode 100644 index 0000000..dfe5877 --- /dev/null +++ b/backend/src/test/mocks/prisma.mock.ts @@ -0,0 +1,45 @@ +import { createMock } from './helpers'; + +/** + * Creates a mock PrismaService with common model operations + */ +export function createMockPrisma() { + const createModelMock = () => ({ + findFirst: createMock(), + findUnique: createMock(), + findMany: createMock(), + create: createMock(), + update: createMock(), + updateMany: createMock(), + upsert: createMock(), + delete: createMock(), + deleteMany: createMock(), + count: createMock(), + }); + + return { + device: createModelMock(), + screen: createModelMock(), + screenDesign: createModelMock(), + screenWidget: createModelMock(), + playlist: createModelMock(), + playlistItem: createModelMock(), + customWidget: createModelMock(), + dataSource: createModelMock(), + setting: createModelMock(), + firmware: createModelMock(), + extension: createModelMock(), + event: createModelMock(), + model: createModelMock(), + deviceLog: createModelMock(), + blockedDevice: createModelMock(), + widgetTemplate: createModelMock(), + $transaction: createMock().mockImplementation(async (fn: any) => { + if (typeof fn === 'function') return fn(createMockPrisma()); + return Promise.all(fn); + }), + $queryRaw: createMock(), + }; +} + +export type MockPrisma = ReturnType; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..3cb5924 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "src/**/*.test.ts", "src/test"] +} diff --git a/backend/uploads/.gitkeep b/backend/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d791ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + inker: + build: . + image: ${INKER_IMAGE:-inker:latest} + container_name: inker + restart: unless-stopped + ports: + - "${INKER_PORT:-80}:80" + volumes: + - postgres_data:/var/lib/postgresql/17/main + - redis_data:/data + - uploads_data:/app/uploads + environment: + TZ: ${TZ:-UTC} + ADMIN_PIN: "${ADMIN_PIN:-1111}" + DEFAULT_TIMEZONE: ${DEFAULT_TIMEZONE:-UTC} + healthcheck: + test: ["CMD", "bun", "-e", "const r=await fetch('http://127.0.0.1/health');process.exit(r.ok?0:1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + uploads_data: + driver: local diff --git a/docker/cont-init.d/01-postgres-init b/docker/cont-init.d/01-postgres-init new file mode 100644 index 0000000..60e1f43 --- /dev/null +++ b/docker/cont-init.d/01-postgres-init @@ -0,0 +1,71 @@ +#!/bin/sh +set -e + +PGDATA=/var/lib/postgresql/17/main +PG17_BIN=/usr/lib/postgresql/17/bin +PG15_BIN=/usr/lib/postgresql/15/bin + +if [ -f "$PGDATA/PG_VERSION" ]; then + PG_VER=$(cat "$PGDATA/PG_VERSION") + + if [ "$PG_VER" = "15" ] && [ -x "$PG15_BIN/pg_ctl" ]; then + echo "[init] Detected PostgreSQL 15 data, migrating to 17..." + + # Move old data to temp location + PG15_TMP=/var/lib/postgresql/15/main_tmp + mkdir -p "$PG15_TMP" + mv "$PGDATA"/* "$PG15_TMP"/ + chown -R postgres:postgres /var/lib/postgresql + chmod 700 "$PG15_TMP" + + # Start PG 15 on alternate port with old data, dump everything + su - postgres -c "$PG15_BIN/pg_ctl -D $PG15_TMP -o '-p 5433' -w start" + su - postgres -c "$PG15_BIN/pg_dumpall -p 5433" > /tmp/pg_upgrade.sql + su - postgres -c "$PG15_BIN/pg_ctl -D $PG15_TMP -w stop" + + # Init fresh PG 17 cluster + mkdir -p "$PGDATA" + chown -R postgres:postgres /var/lib/postgresql + su - postgres -c "$PG17_BIN/initdb -D $PGDATA" + + cat > "$PGDATA/pg_hba.conf" <<'CONF' +local all postgres peer +local all all md5 +host all all 127.0.0.1/32 md5 +host all all ::1/128 md5 +CONF + + # Start PG 17 and restore dump + su - postgres -c "$PG17_BIN/pg_ctl -D $PGDATA -w start" + su - postgres -c "psql -f /tmp/pg_upgrade.sql" >/dev/null 2>&1 + su - postgres -c "$PG17_BIN/pg_ctl -D $PGDATA -w stop" + + # Cleanup + rm -rf "$PG15_TMP" /tmp/pg_upgrade.sql /var/lib/postgresql/15 + + echo "[init] PostgreSQL migration 15 → 17 complete" + else + echo "[init] PostgreSQL data directory exists (version $PG_VER), skipping init" + chown -R postgres:postgres /var/lib/postgresql + fi +else + echo "[init] Initializing PostgreSQL database..." + mkdir -p "$PGDATA" + chown -R postgres:postgres /var/lib/postgresql + + su - postgres -c "$PG17_BIN/initdb -D $PGDATA" + + cat > "$PGDATA/pg_hba.conf" <<'CONF' +local all postgres peer +local all all md5 +host all all 127.0.0.1/32 md5 +host all all ::1/128 md5 +CONF + + su - postgres -c "$PG17_BIN/pg_ctl -D $PGDATA -w start" + su - postgres -c "psql -c \"CREATE USER inker_user WITH PASSWORD '${POSTGRES_PASSWORD:-inker_password}';\"" + su - postgres -c "psql -c \"CREATE DATABASE inker_db OWNER inker_user;\"" + su - postgres -c "$PG17_BIN/pg_ctl -D $PGDATA -w stop" + + echo "[init] PostgreSQL initialized successfully" +fi diff --git a/docker/cont-init.d/02-app-init b/docker/cont-init.d/02-app-init new file mode 100644 index 0000000..34db145 --- /dev/null +++ b/docker/cont-init.d/02-app-init @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "[init] Creating application directories..." +mkdir -p /app/uploads/screens /app/uploads/firmware /app/uploads/widgets /app/uploads/captures /app/uploads/drawings /app/logs +mkdir -p /data + +echo "[init] Application directories ready" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..84d865d --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,126 @@ +# Custom log format: use $uri (path only) instead of $request to prevent +# query string tokens (e.g., SSE ?token=...) from leaking into access logs +# (must be at http level, not inside server block) +log_format stripped '$remote_addr - $remote_user [$time_local] "$request_method $uri" $status $body_bytes_sent "$http_referer"'; + +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + client_max_body_size 25m; + + access_log /var/log/nginx/access.log stripped; + + # Allow headers with underscores (e.g., HTTP_ID from TRMNL devices) + underscores_in_headers on; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript font/woff2; + + # Security headers (repeated in location blocks that use add_header, + # since nginx does not inherit server-level add_header into locations with their own) + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none';" always; + + # Serve static files + location / { + try_files $uri $uri/ /index.html; + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none';" always; + } + + # Cache control for HTML files + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none';" always; + } + + # Serve fonts with proper MIME types and CORS headers + location /fonts/ { + types { + font/woff2 woff2; + font/woff woff; + font/ttf ttf; + } + add_header Access-Control-Allow-Origin "*"; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Content-Type-Options "nosniff" always; + expires 1y; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://127.0.0.1:3002/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Serve frontend build assets (JS, CSS) as static files, + # proxy backend assets (default-screen.png, setup.png) to backend + location /assets/ { + try_files $uri @backend_assets; + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + } + + location @backend_assets { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy uploads to backend (images, captures, drawings) + location /uploads/ { + proxy_pass http://127.0.0.1:3002/uploads/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + expires 7d; + add_header Cache-Control "public, max-age=604800"; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} diff --git a/docker/services.d/backend/run b/docker/services.d/backend/run new file mode 100644 index 0000000..619c9d2 --- /dev/null +++ b/docker/services.d/backend/run @@ -0,0 +1,32 @@ +#!/command/with-contenv sh +exec 2>&1 + +# Wait for PostgreSQL to be ready +echo "[backend] Waiting for PostgreSQL..." +until su - postgres -c "pg_isready -q" 2>/dev/null; do + sleep 1 +done +echo "[backend] PostgreSQL is ready" + +# Wait for Redis to be ready +echo "[backend] Waiting for Redis..." +until redis-cli -a "${REDIS_PASSWORD:-inker_redis}" --no-auth-warning ping 2>/dev/null | grep -q PONG; do + sleep 1 +done +echo "[backend] Redis is ready" + +# Create required directories and set ownership for non-root backend +mkdir -p /app/uploads/screens /app/uploads/firmware /app/uploads/widgets /app/uploads/captures /app/uploads/drawings /app/logs /tmp/inker-home +chown -R inker:inker /app/uploads /app/logs /tmp/inker-home + +# Set HOME for the inker user (Puppeteer needs a writable home for config) +export HOME=/tmp/inker-home + +# Run database migrations as non-root user +cd /app +echo "[backend] Running database migrations..." +s6-setuidgid inker bunx prisma db push --skip-generate 2>&1 || echo "[backend] Warning: Database migration had issues, check logs" + +# Start backend as non-root user +echo "[backend] Starting Inker backend..." +exec s6-setuidgid inker bun run dist/main.js diff --git a/docker/services.d/nginx/run b/docker/services.d/nginx/run new file mode 100644 index 0000000..2804283 --- /dev/null +++ b/docker/services.d/nginx/run @@ -0,0 +1,2 @@ +#!/command/with-contenv sh +exec nginx -g "daemon off;" diff --git a/docker/services.d/postgres/run b/docker/services.d/postgres/run new file mode 100644 index 0000000..7790bb7 --- /dev/null +++ b/docker/services.d/postgres/run @@ -0,0 +1,2 @@ +#!/command/with-contenv sh +exec s6-setuidgid postgres /usr/lib/postgresql/17/bin/postgres -D /var/lib/postgresql/17/main diff --git a/docker/services.d/redis/run b/docker/services.d/redis/run new file mode 100644 index 0000000..6134562 --- /dev/null +++ b/docker/services.d/redis/run @@ -0,0 +1,2 @@ +#!/command/with-contenv sh +exec redis-server --appendonly yes --dir /data --requirepass "${REDIS_PASSWORD:-inker_redis}" diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..7da8379 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +VITE_API_URL=/api +VITE_BACKEND_URL=http://localhost:3002 +VITE_APP_NAME=Inker diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..cd34ec9 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,899 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "frontend", + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.4", + "@lezer/highlight": "^1.2.3", + "axios": "^1.13.2", + "codemirror": "^6.0.2", + "html2canvas": "^1.4.1", + "qrcode": "^1.5.4", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.10.1", + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.18", + }, + }, + }, + "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], + + "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], + + "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + + "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], + + "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="], + + "@codemirror/language": ["@codemirror/language@6.11.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ=="], + + "@codemirror/search": ["@codemirror/search@6.5.11", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" } }, "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA=="], + + "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.39.4", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.27", "", {}, "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@lezer/common": ["@lezer/common@1.4.0", "", {}, "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/lr": ["@lezer/lr@1.4.5", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "postcss": "^8.4.41", "tailwindcss": "4.1.17" } }, "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.1", "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.1", "@typescript-eslint/tsconfig-utils": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="], + + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "autoprefixer": ["autoprefixer@10.4.22", "", { "dependencies": { "browserslist": "^4.27.0", "caniuse-lite": "^1.0.30001754", "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": "bin/autoprefixer" }, "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg=="], + + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.3", "", { "bin": "dist/cli.js" }, "sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001759", "", {}, "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@6.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.2", "@csstools/css-syntax-patches-for-csstree": "^1.0.26", "css-tree": "^3.1.0", "lru-cache": "^11.2.5" } }, "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + + "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], + + "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-router": ["react-router@7.10.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw=="], + + "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.48.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A=="], + + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + + "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "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" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], + + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e95951d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + Inker + + + + +
    + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a308a00 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3993 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.13.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.10.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true + }, + "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==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz", + "integrity": "sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.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==", + "dev": true, + "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==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.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==", + "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==", + "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==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "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==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "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==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "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.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "engines": { + "node": ">= 0.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==", + "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==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", + "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "dependencies": { + "react-router": "7.10.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "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.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "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==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", + "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.1", + "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..433ba0c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,54 @@ +{ + "name": "inker-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bunx --bun vite", + "build": "bunx --bun tsc -b && bunx --bun vite build", + "lint": "eslint .", + "preview": "bunx --bun vite preview", + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.4", + "@lezer/highlight": "^1.2.3", + "axios": "^1.13.2", + "codemirror": "^6.0.2", + "html2canvas": "^1.4.1", + "qrcode": "^1.5.4", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.10.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.18" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..1c87846 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/bmc-button.png b/frontend/public/bmc-button.png new file mode 100644 index 0000000000000000000000000000000000000000..21ed36b7efabacc50ffb03203ceee96a570a43ad GIT binary patch literal 4811 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 XwdXb%E2%RuFfe$!`njxgN@xNAfczpI literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..e691192 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/fonts/Inter-Bold.woff2 b/frontend/public/fonts/Inter-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8fcc4321c7ef938a64db296f2ce380c632e6c06f GIT binary patch literal 17784 zcmXT-cQayOWME)maIIhv0MR=ZFfh1^LBv3OR4i2{{g_4O7#mxHeQ=0lhzmn2iy#MA z<4T^!_I&PE69z^zCKcwc6cz>6772C*9(HN#CE^9oPd62AU&X~yaH!XXLzbBw)VQOc(Z8xpImK%fu{0 zN7n-yIXXH=gl*Ru@OP}Ly}#*s&41nPY>5Tm1lk9)agx8HbAD8unrPRevyl1E*TYskKnfZrBvsol{XRv=(c=q&c zPm-O3`QmlEC;ApOOD%CTKX5j$=yLO3&fvL)^FH)gmHzwr{N8!vrOB^NG(!w6w67Wb zV6bOvKXg!_C4s}<hRie6=HUn0_d?)-ZBGdg945^n^#CtPDXtubfbhDA)5Z5vO;IQS^&KbR84;6E`$ zZ7!2P-%c;Z(7fWALCYsCJwG=#bYn^2F5Np*^`fr!rDn?QyXIy6{t#clGVQFVRoAVB ze_1QOVh)L2t|rc)BmXc-XMv4!@)Vx`3C=THW9sH5y{Tvkj1B*G)LcWPs4{2z28}s~ zkKA9qSNKLQd#+8d7LSa(g;w=n+p_A__cfNh7qY4r)pT9b;lZJ#B-F+8&b0pg`r6BT zR9}@iS;jwD)3W;1biIe6S>HdnEu1)skDs4GX#q2Xgs?)1KywafV)jFUZ^FC}6*L9< zrZYY?sT9yF;5jt^FXuz~Kir2T_B&=|O!y&KH{n~heTT?WJ|*4E#uLGD3LF`RtvXk3 zz0TWXI_Z3WpPh*33CG_ul^QM_%_WQq2RM2rZep9&#&FiqI)R~b)%4|y1U4|1&anyZ z%{}p7yFcS!-`=>W>Fu6R^t5hp*Yw@11yuWTPN?xb6M*B_w zCRZUP&&f|b{ja92^7_9vW3|c37t1A<&RBV4L*MEW$D2-;4wm6NV|&-u_{lEl>YBji z$?9+RjxDI`R8Go6m1U>wSFd6U@mv*O{V!~C`0&w~tJ?m}OOo=~oKRlIf zL!zxeg_kz0*C>2-OQ2zIWp>WJf4ggJzwRtGw_NvbtNz5hA1{3Rw5m*Emi^&ZhYh#n zdF{`Q(x0cbtmc$g0Q<$noE`ls3uW%vy?3%(yZva8Bv6=7K+EsdWcT7X)F%9Eemop>p96PavQCfPA^UEjaU!Gg%J!_eeUv$RZHF0Y^ zO1&5E`E<&4TU_<4%csSE99_C1GdH?D;vU~l4V|5#p0OfEX6`#rUfFx+I%mU*oLXVS z3&vVMW@XC<=X>1|jF`nWsc7$9@c`e7Lffg)UcV#brhlu=eY)%EH@^NxeZks!uebcZ z^K13ZA29(^43<{pN_k)U%=lk6=4)>ZJ`jjU<>wa3( z?+l&TfZ3OJ{5jrJlfCR^q|}}O@tmF7`?e$=DHL7ZvMN?8&UDfAi<-}7D?iCT!}Z|F zpEs+fzWR4OEbwIU{l+$GjO3clGmPe}+bs$8@fn88|_FXK|Oa<axd65xuOhCNSX?lI<#j4k>TW?M&_A5-A?(V8} zzF@szy5@teRhC8fjl|bqJly?9`rw5d)^lI%z2R|Pa|u)BMD3)k{!}kD?ke}0FCNYk zn!f$xhuy)8)`nhZ4gV9`e$_w9fA#cv^TOQat8N|J7gE6LowwNhNUx)GX5XGiPb)3z z*A<3uJX>`~Kk35kK0A$f9h*xm=YENKs`^{8eZ@J`?K&UU}1ypYJtmlkT? z`g_l=&vWur(O0iSTwCU^+4@&M%R1uORJkC|%grmVMn z=T;@<+V-{7?ECCIgTJ@kL;WwitAD9&8J ze(j8x&e^qvuU|T{Yqf&Jzq{-H*F|l4Y2YT#=%p&${wNgqqRR#TtuF1;-`qu*y(!EM@Xp28uJ@4*Z+<2h3b|c&7wskR`D&JOYm6O-tj6W;5FGN+LrZ3H9@z=K>OE)n-a!Q*U z8YkZL=guRpGYfN{Z+jtGQl@)$ZO?4oqNdRPKgG)z9$wyi_3=-Aj*ygNGF`?T@76Y_ z6)WB;iHLAW+PrmF@@b!I=NBgMos~?P?fc1!;cUw5Ypqk%6$QjRgP;5ey|ZRQ*2j_! zp^H>bzOLQ*vaBpO@l`NmG0zg+@0q6pc$Zxg=zS`e_4;bK+Y#ID-;WyIKk{7L^I?_m zjca@V-Ji30_1Sytm^egCcywYOwO;P#w`X)|YT*!8^irH@J~j2A(A?n1dl>t4H)hv- zcbaoMaD8$3P9>&D=A{=5HWci5;Mw|Yqxs}|DBg_O3 zNj7V54>Vc7?BJf(13pb3zCNf`vWVlc?tioEZJFDe>(1;aemq~mz{||t&RrMtD#w`5 zIPp}ECHJ%wC#{aO=~{o2u~@XA_GQq4zE~B5CWVNOfHhKb6V;6V&6}(>XNu*lou4i* zeUQ0l!o)>aG~?FuzSb2o+ol&g(^~(yX!zx?3uedfmv#Qn@aO)PTUXQa!x>q)q}a9{ z%x!q|=E>9-Jvpu46!vBb9G{?Rr{%av<)q<-^}&WVyN^m9lX;~tlryK(_t%sDXP-Sj zzAOCrP`$xpf}2$Q%+uEUQlOK@=es)R zoS5FS#jk{xFK4^7w4;*Sbw=91U3t?koUZw(*f~LE=hTN1ACkHLmfTJ&-WGSrY`xgL zi7)+%tRC7lZ;Uj(ccXmU$ASeGei2o-+e;(6m%mzytyzB(MH_O4W4>7GSRi~h0=BJ$dSlb)QInf-1j`|(Q` zD}Dw#xYin8PV!YP{_1-m`WX8W_wKcE<(?&?eWf*0<-AV8)hZ<(E*V`P8#pXKiA>N4 zTIADu%UI=OV{Vsg{*B4peWvxl;`;wP+&iCkd3~@)RBqhEvqb@W z8rZHZSZH-;kCvlhlF2TEkJ-hH9UGr%hHN`1u#$NqOURdbE)7@WIeZ$n2urd#=rktX zoGD#)IOSQx1x7YYDgEQ8CfHPcRK8&xJ=v_%sw?D4OURmNt-@VtD;b^pBp-`1ORhe1 ztn#YF)zsc~yH*`NrL%RH_B@YyUras+ci-T5UYslWaryokU-KuPyEXCL&yBCn{nnM) z6SJp7Q#V(nINVnJV z3>y?mCmwHgk`(L7HE(_q@cp3YTbcfNh3LYE$+1VaUYBE+KYOTOv|aYl+W)I7b;9O{ zzbiGk-M&n&NX5FnUgc-)mkX{M5tq3qzW=Geztz#^$Z7?qOAbuD7nqGVu>LX+YB(ga zS|oLKfN0hQPu{Gc=VuPRDXcpe{bkdFldchawlV%?Oj&EXZJz8+Ru5s`$O~%PX{lLYj+djGtg|Utnf*12-wu(Beej$2G>-S%Q$L^m_ za2WnERERQM;1trTwdHZctUsF*QeOR4kKUfkF8@)a+>mw0ev#FslcX*0I@=ue-h0)( z_G0{x>ntm0hfH!U@>A4Q6;IZ4o$81|~u^l{O@*%9*s`^@EZ`p&NY`ju0A4r9M z)UcPbD`tohWSodfUU7@nJ_PS#8N$*Wi(u#XPrh4Ssn!k7xS(|E)QjzPXyMPj%hC?d)@grBCkMDcHU7Oflc#fc)?s zuUx(Ew#nT464SWssO|#Gj}1BguXT2pMK!VXOqq1a>T3ea%H>5u3ri1W&Y4)?ESlfJ zuX4y+ilOz3Is4}QSw@U%4-Uoc?%h9G#^Bt8bItl+e@ZFmhRn-d)BC*giPwxzDF)9Y zC;ocl?zYnC-qm3DiB@8Bc3ao`7H)d1FDaW7cmB$Qd7YmvI{YuDa22;vtdO_}+T@xiEcl>q^JN)d_XSJil!(5@gp+SkvsZsMRRL@v&IOgDETa zS6y&ArXwLVb;bN&7Y@0pN1W0M)UUXx6wB?nb;~O|aowy9j^4XeBd<$tnemXF?O;gz z!#uCt!ug53VI6+gqf2Lq>n5&fSiI)a*DT+!i&b^wK2(WqIx|K0YS8Vh1N`DcT82*KJvIkzd@|tHAq6uW-bch;+&Z^8 zlh;dFc^yxz&(hPgxZZ#I7&^&gXW*%hVv}-z=GDs*6QBBgRM@!q(t)hb8MBLC`<~Sl zs7iEnk(;bl7^l?hvsCoR#sJ-O>H@Zmbu#l_%&-sGcKZCUPwj!TOkY0De3+j)Z?Q>k zWayupG1FtUwrcxkN50g4tXJ}JUu}mZ_6>K1=qu`FiHoJpDaiUTvPe-~NKj^vQFcDz(KtRV%M-&`La^ zeshs&#msbN$tinHxqn!?uCtkQe%l$Pqx0ib^>^Q5?F}uR7j%4zX86LOsad9Cv71vQ zn+&=AJthVQFf$)Kad@vqS096Zvrh{5FS z7rvts+}a76iuzg8R!W>X`ivub*4@BuW`_;?Okc(9cv{+}>bL9g?0~%r z^LV;eESp-V7IDda!<<*<`_FTCXxM#DD9y=;rTr$a>wYQ`> zv5%MU;K!EjHP3f635m2nm^5wGm)yMS-8Zix%N;YuD|L zbulSOvud1t^zOC{i$_!E)CqnIGM9zd#W8HNc-EG_=ehOqDH@p?l1CaVZ?13A6;jFQ zX5nNDKCWK(_WUn?H+9hjvCk6w1h3d1|Fz06{N?<&8zT=)3fq=(juHN>kHoVdj5(O>WAZUbFpjVtP#ReQ)`nx7OEP zO!ihdIpy+M8fN&Rz3?QaLm|_|X6u4I~w?#Z|(toHj z$(3{K7UL5l+HZ=^F@LCeRK?n@4ir>whHA|I@?S#%}8-V7Kt+EQOlW&n_RC`qS{V(#4gBUtNx!cYlgw zZnp6~j@y0f|GueLO6Qxv`s8G?z>lm44EjuKCp#QFlN>YoCj+a;4`~HE4ZAb)50zt| zUpvaozf0n&a$ zu;rOkDTf{tn%I)V*h5J98j411H3~cRCDC(@3=>FvCB9(%- zzxuN;t~%?ofj8ifjUIc=Z6hYPT>(qN&jvfqQh%O&?`ti`W`}RtXCL2qw)4ifT{Oa21UQh)CE>pzzDerLPKwP*c=Dv>Ql4DZDFPhh zg%}vlSw25jTe9kKU3R2l-skC+Dwn_VBv(2yZS?V)q{7L-U|=!x!jAhz&#RA|={V@# z`aU%3$|JW}X0E$Yb7sz*2sdL_`eWa05w#g8VzK`6Qv+w5I&-E+#nW>V14F?%%j>q? z<_9%=42+GHJ%c8xFfhz1K3BTC`^k!)Lm_EtX)2yxlUNuUiqGxLn_RZxx?5t?%$bs& zlP0M!GCY`5{O)1xvL5~4Yg3QveVMrRTGZ(?XV$D*)z#7QV%NIY$L3D?o}cqWM{>Vo z-+$|ME;kz=8kgVIG`{dH`eu{ezYJclXPX{|b4TvvKOwEt{~@z_)4W&n*2Q(qyZ54Y z*~E9-#r;q3wf?=i>g%gYj|;C~aCw#N^zHRaYRTr=|3FA3;iW{JG=i;!Te> zU5c*txHcpI5P#awX=ye4W9P?iH~*Bstr-W~|Z#7%za8K(}ce+L>OR<_yq)~F?nKov3 zk#GGus7Jxsk71R9))Li9c-Nf>HO*%rJUu!(#fMmobB#;f<%mafZ9x zGd>$T#EE)%GdmpfID5(2Pgt>|z*-Hzj#y%FBuWQE}I9SF|>lTr6NNu;*f;tX}cTFrBUWPkr{L8%|95 zv*5sui=R6daR~jN+*I|V(<_^MOW$V6q)qB+8muvsC%r95RQDj(^bykgs(AJrFZ1uE?B|N6lc^LOg9gv82L*;DNgicdwQtGXMCU;gj! z$n);z+_WdZMVZ=Jt{E$vhP;=bZs5A(l;Zm3)!%-mII!tTh8waa?BDj+q%YD{>)ufv z_T<%8kFWKtPMWDP>(R?T+arQ!tdnUIubi)71yQ|$at#>CBaQW@}*nf5Y z8m;x|%Pnly-m*@5t{di`-SsB2VBSjSL!Kx1bWLgK?Q!i63BB@gRhhZxg5=pJjvUf5 zdAHke+U3W#7dm5A3XJ2FY-Idy&FOvTFtMmC=VouhW0jMEIxXa2jlPN~!CI<7CcbLc?& z!H>I?j;UBSTn$;Zr%&jtdB^7P=S%B97=Ya&_C1@Oul z?stA)vP{M-f8~Wo`oH)Y^SRIeRpocs@FDF$*_X2F3*SxoO|^}Krh7F+>$@Iy>j*Kt z^Y_rN|L+bO8Z*4SI^E-p!}RrQ-Yl6XFu}a~)2Gn-2|`!cW+%BXU%Mn{+k7>RS8PmQ z9$$O(Rf9*&`eR1;mdTT(_o=(iIn&Bk@g#ZLr=7`W`@R=yn-%X5)SGz3G3w!N4Ia}7 zGnUuJo#Lt`8aIC!mOqkO`n|D>xofQ!ZzQi`&AqY!3VSZf3q&y z#C@;2JpWF{L5&w7l{?uU1-$${#VSGeQsTCS%z5RCj+F)HzQkLdG2hw{sU5R_*Ahpy zsN?t7Hb#BVvzx#r9_(}U#{ETIip$^UGP6CfS}!-J{NO~cwkg~pr(6Br1WdGR&(M6Y zVj@)BbN6?0k;BWAR%Hvb|Lm-}{kwVQg3RgPXC3at?lwAgP34UD zSHAZ<{gFTFfXDPT8|5A?Xce$X+#6rIteK5dB3G9!b=%VqqTKJAoGf}C-cyy!!wx67*!*)6RR zn=ctC<$5gi*0v)V*VqdBj$EkOsk?0!e|uGlNAJUT&lk46&0Otoao^pVNuERYcF(cH zJ?imS*-poEyq*wI^zE)7`+Y?Z`}HDIt>zwdICpny)$giTudU~1W--1A-@L9M{)n8W zCi|pamA)-{jlOSN?B_9;C7oSiqwIDsbgtp0!mEBidJ0RYTk~Grm0!E*vDTz*-N*j> zU%MnJBc|m)ziGyfo=ppTK0c71p0PmV!itH9r=9+}?f;|`w?*VGE?sgvbYr;tS%nCR zOD76{6g#cn^f=f^sw`cV>Gm@-+3&GmCp22lzI*@1_q4f_FT1HO>8+l5YZJf0=d<_k z^r@cqH;M_VGu|Aud{fkP;n(du{dj*_9!Z(-$;r!UW%r#`GSXkzzOSwN)7!5sS`ii} z{O9+@#o-KdZ;6#2;(cuYK3zszA;f2*8{64UT5FFe3NFoadiu0GW^$AC#H|@JBH4Fe z2^$8=HG8Cn&7S?z|LE^b1!)B%70pwdjhl{b(hpkja*6wf-kyU}a(!usbu?Wqs*dg0 z`{q)Cy6lJa*?g8WXYm9t*}Z9=?)z1To}Jo#xGh@SKAc19I$y+xrwyCCj;;H6btE3?kQgNbCI+8ai96|$^Mxa??_xf)91HGW^Ib~%7TJ#*JECOn0M!kl8Ly# zPoiR>CG*ZR(=^h=o-=K`VDe5lNjEUZ#9m8&^Q;PqkV}VzWLGYm`*zA1MfpFH4lJD4 zo4?MISh~6J;?1nm&dc(8-o|Du2~Yg*`$#9Xt<`FKR_~cM`-AJ^k8chin*Z@u*{!L-qJQ(Ar`m#v-*Q(2+`KAUYb?=thwbkT^-~8wh%+|}_?bcgz z{(=`zc9z8pm#f9eMH3TZwO22Y3l;x&YLOj%jMo}iXyv;N9S^^3(84BZQDGZrlp<2)?(c6|oZ@zd#doeq1{ zJpVD->g$Wse_Uo2?o(JLQ>=1kuE(9Ce;>nlO!%@mezI8Cg^lOW=RchN?#Y|u$HbS< z4S&3U*S&4`4l|z4yrLW{w7?@Z&Ri^v(_r6=l!?1^x4iR+R^PQ^HRqqb7L#h9zU`Kf zdv)gM!KFpNj@O6tzJKw3Rm0*O^Ose(i`JK?#QW`ZYO%8K^8Q?1RwrFI(OYp!%C&Hp zuriqv{rsazS#S2gzyH9na7WK1mOaPoGyc2s=$y=Xe0uuP6ourEKP6|xe0#&P;Pub< z^L}L{tkJ$MB6HSI;&{31q}x_A-GryIExe|C-_Y@qc>5b`?w$bCYa!g-vG;e@-Hmlk z`C*$?;UTL8{@}|q#uH6lO?7OJyQ)5Ni?T%NH zCq&<~{q3I3@T>TPj*Q(3ruuDqnF3G$P5gM-U$sWDwf(>GY;Ml`UdF}RI}>=6wx7(s z;yS_q^+TWD^xeCsPBXT#6_qXze{0LuJ13)!?Tp06xtVM=2@QN^Z2pRgvWJ$NB_B?< zpUe6E5yOK$t3s#m*|0iKE6;$l;zIT6$SZrU&XTjaJ6&Y6zY1%ywsQX3oy)VY@2Gp6 zlkwur%1t8v*BARvS#h{4Y+tHaPs;U{Z*48?$m|6z4*E2 z>iM&|_jqTWwEJRHcU`h2BCw~j`n1N|Sr=Yxf8ReRw$4`S_QUyC5^MKeQQ%qH&&aR; zMgQOK+PjnX)D}P7-=Ol4H70Z6oA&Zn_6@4sN&#B4dirE%>dy@MQYJc|=|LjX2ZbFO z|9>CO_n-DrsoG09ufNrN);8bO1={Hx2ak1FT)MIGT(vwOk8ztH7ie=0+9NqvFh4uy$eL%7 zniDSx={>xpmvKeVvL{$znVrk+%MuOQ-Tdrwq5{DixCO)2o~3+e3X2c$3yfuxlyy8X z>x#`<+nFYZCG!|vcHA~Snd5A|M9=_xsh#Sy?r_|d&CN7TNevId(a&*T3ZcFvO5^Em{ z_B^(g+o^J(v?=ovSM|HoXPDPdY&&1{(wY6Uw9D&=>&uegZ#Z@~(DTc-wSO7?H&y3I zsQPD5|8DhcPwgts?{XCve!psP(l?M=UU4bC>sE4>?~94olG$<=z7mT8DSk#$USclp8KmlEsDy2Yf2;giST4?ZFLqYC0HN*~*uw_51C zzWUm_M|K|zg1H5b+UhgB6n|Sk*(&};xESNp{~x}`>Z^U-BN4i5!5`5B@1Gx5zq^F> z@A28rCug?G_to*fP5pT~?9hJZsJHXiUP|}1VDY|Q-=VRmaE`FWCD~ec`-uTZt}FXl zRtSjC5tE!ABc$eKU6xS)^M>xmAC~9jmu)qZZ_Rs>f4mN z^2~~v+IKI0FSTiB_m3(S4Xb@?Cztf7I{j}ptMXR*x5#;?%R0l!s&6~CiB1ptD0B8_ z4)f%Oc1Mx>M^DJ4((77&qLt|Wa z#?6(G4Hb3Y&Bvy&`RO(z*?*bYW@k#DUM@X*%TVu+i>%?wWlB>+#UEE~-Zrmr_dkQ~ zNl9M*j}osG_VWr|k$$-K$BZW>`5aC19Ft^ZEv()g4b=a%kd1+VueHZ+z@j}J!>5o0SSFUq8 zP|S30p}xVEgV!1A9vHsf;Jj*+wT!j%^RTDEIsLP9?ti;{%6g*hgykn|d>MAMT>g3f z{?V&O{_oB&ikxQgNs~2j&m2Z^&R_Y?@3c0$>v=DczkK`slS3Cdb>{26n<6mF-$SPO zgv`V@uNJ>gWM@)!XY1>$I9iY=9JriOeS^jMr(q|STT4fZI>u{gF4S#(T6$oaj<`sm z_^L$?7u9TQ1wLHRR9F)IX=CY{*V&PZT+71PyBBU+rhI?*1gEtPjtAbZ`gl{HRs8v{ zUmFsNfB#r@^3@xQlT~Z0q}0D`oX7qvEK+vK+^YXwjZyLKqwBeE=jz|~ zbah$VH1}Mi`ko{6?=TzBnY5>4%HqTi$J+;I0|v`&EK*X_HXM#RTVueUs0wP=x0zURfQtjyCI)W0uk zdB5&?*Leo1lg(zU1a3CG=L=K#E%!p$>FYeLP1Ew8#rCs&U%!AaZc*0Nt-BU)5?Oci z%<_vhQjVoO{jU}je9&GZ_ICBwq$4i^B31Ls+U{+UFbq6=eb)K>Z>IhGt*7iRGRO*l z_R}+PVZ*HnDrGChZxpcyM;^LU^;#-hzg>El+{8)iH7)+LPj09+H{6vS_A8@x*5*I= z-*onUO+J-$x@NxW15TfBe}nY)oO)h=cmIV;hO94M3Y+TA{UYJjv)8jr_E6p`ZJif+ zCm7#o8q8-cV*gopNFkwrw$_A`La&UT|J!Evd!o+EwycABEYD(_)e3AvrkZeAWZZPW zqI>V1Wai(HdHr|dm+e@xOZo816Zh82u$eHdx17Q9Pj2d3JwZWTy?Ij^J((MtA7}5G zb!Kzn$&=3?<%q=TaRf8%RExMCTH^X6UMld1E$=)}t%DN(t8Ml>DE`}WK&h*LzqqNE zCR=zXtM{Q_f5j%;W#BA~{5X@lT6@`+mXOwG)4rd3<2Y@d=%dvKpM`8J+0wsqO0z|z zoo}+^k6MvH7nN757KP7`3cGvfwZSxKKS92b!NNTpKm-a9LU?s zHBVg5;YJJRfx|5Mg)3i}pP!sO>GI`dp?P=jyS!W!xz?}mrykGoMPY8!G?~CUm zv-fREE8p>Q!)Ye%2Q^K1WNPgh>96iw5%c9#2;7yKvx3?iGG} zT~jNO7Wc2L-hTZpW^!EL-|b%Td3(9!*DB|nAg{<-KhNHg*?c>7=E~0Ak5^_dkmqt} zh*MxFe7;rodF{ph9Q=CH=bMe~&bR&k@Z+Pd|C*IC$`g;5hJIXO-q54$vFw4TzUaOD z6?QkKhTPceAaqk;nvsOr1?G+U|Lcl3u8OE~DB)mXYFWNtQ**!H$0c{noEF|ST$;Jr zbQ;slVyV7A4<(-5OZF*=TAi#h6!{J$B(-Pg(JO#cSoV4>< z9CMH9@zb^&*Wc{9p7>v9?uRo;(bqG6PPwm_uATqypxRwM-R~=}^=w^IR($(y=X{NI{RQ^U)aq8)L`%fwpPRI}f1j@3kr%ec{`?hRGtWn;rayJ_%RZ=dCsN_} znI=v5UCbU6^95W9I|SvtyWU1*>$_=aK_FyN1eYlTBp}9w-wp{sOXyL-gVBzK1Uwt&4{duwaGqvsrs$USGbbl<&st+^pGtM9Eo(KqessRc{LZ~Uqd zzZrB{a^03SO4bImCcjg0&9Bip#~PS(>SOxv(%O%locuq9{8HH7u{CVFy*+nY?4zD5 z4ry|E=c9YKf4r!?$M(j0>4=ARv1=U{Px$WMx3Yc;lj6R+vI`3eVxKL{E7o6TZTsU* zK=quUPx5S@^=*hl%GN%m|X1#J#3AIeg~f%@g-WIqC1ny0VXJOT2H4?-%iM)~zKD zOSc_c+!gh6pZ6v$?FP<;Q=hJR@T!P|Rr~dg!znKg$JuxtJ^xI#CGvo`){M~32GgGf zcV5@T@385ab=UW7@#R{fSD7-Ysp+DU`5DPmb6h&zcgh{tIiGOF()cv{XZL-*OD`?& zpUNs_mEGF$6fV4>>t$*M>gzIB@G%!JE%6Iv4ySz4o|f zlqHFIZob2tthT>p&)e4>IvM+1KO`k+oV#-KcVu2$d*E*thp$Wp`-QijU|83|<9(q& zXqQfu-$d4g$)@Z2MP##UE6pBVjd}FH>#SJj%(PcK7pm+^%q*VLGSlzW@-+6{JCf(r zUX?O*Tem-RV|(r4P7$F43vc^xKJmX@_$1@&s_xf^j~^c`>?~dvVf1H;F#C;M}K!8 z%BwNj7d2hVal_NITKth0lOG2au0E)q&Qy`W^pf%$8NU?aK_mf5*@NJeGd$iFECsCB-Ei+5tuRI@?$#GpMUZh>Gf@xms#3*)=D#_WCKw?};v7?mSHlP@8I) zn-+EGr2bw@f5#u*@1pt}Kk#kc!Fzd~_|LERlzb}>R<_S}xyH7l=AQXc&+S6T8}9#9 zv|Dk*&3JXwQRnW{H%*+LWI5_Yq%v-N&Ud<-aaMt~UUh?CI8%bRvO!ccaqihgqL*fU2m zF0NDg=dB}=kwH9{h=Ggl{R{- zsyaIRZ9G`2S5?LL@8gP+>N`g>-V}KXX5Y{~_21Cd{rW+BwyC^K{5dvVUoV>-qA>6#7U)A>&(uTJhR(*L&i zN_D|~HfesP8x5y2E00^a^YVLsPm%Ww+qAV}+lC0U6}yU$e9oOGR?8=$QE^avoluRC z@c#b|_Q$3)cU2$0o$*^F`F2EldixG#w<-Bg%df6`DSa>S><59*7H>D+de_MD`RmD4 z+xmH`*RLl0o;z{iWhWB4M6bs&({r%gl+urtanS0Kj z4}7{@jJ?wGX;8*aKff(^VrESa_nW{FI(2?<_3yhAFMZql?3wM))veh(Z9je5Yq^0f zd#`2DCrjJx^TpLEPpic)C_l07|B@DSXzNlI9uu?8h1b_53eS=<bl&)KSbf4;G){r0`?|4zMJzBbfI(tGY zy}dSjFY#gGFg9vJ793(2FvIA%Er3<>(1qF4ekytbHDCydU|{F>GbN0 zxUN~Bw=VeJg8#gIyG0oI-!-s*h&!}|(N;^z>wdUeocr-RCvR)$m)jU#RC{KybF#JA z^LKM4@@r2YoBOC#ZRO^-qW)3;=TvweKlk#>gGV1kba*utg1??wDOZ)Z^YBm4Ylel> z)IStjly7(0cDBx>SIB_b({$_li|)UV8>W`lb86hN*Kl7ZVQy{u@&?!cB}*z;cWGOg z)@&3kV&ROQ@i1uC;m8>AeXKE;KNKxCI6ife=j9@w(oY|{zZM0@-PpeE+7BD0TAg#p zH?CZ%cD8a+L;8LHfLAy3{waTmkk`6*MDgyO+xo}nEY zhPfqmSKt2`9%j$wV|}nE*Fx&3`-+FJ*ZFtqT7L~!NU&Y^f9(UkivCG&&5keL`#Hgt zqxeSTKe2#swGTrDA3P1d@$1a;1Kv-9|2=lTG4bBSeag0p4<*(8`QwLMk*x0pit_x71A zYFeVZsArDtq!YiYK3)@K{mvDmtn-EMXT&AhWFMU$7aS$GZom7+jE#`^hc)jeQzV789%a-k{k5jZ9})jI;YI z^s9HbDBO3Rxa6EHZ!p&$@#$5azso}U&&l!kyy*0@F{+u*8OFpod6#Kq-1l44xh9^y zb8Kh5ewVE9W6q%M9;dQTDQDdJ7SJs0#(Zek;z>crc$3UTi%+`o;QCV z2PZFh=dek-@tfVZ%Z$^^Wkgm~9Xqk=Lx0Yi{>O)nStoVb?iK1^Qvv7F%zb@uAjDUZ){GMPR!SX%-7iY72KNmtQ$oy+>R}@Iqjdz{#WCUibeKg z&&g4o(JYaVmOWRC(6}nMeAe#Ht%+L~s?Ydw@D+1`e&_wug(q|!*Spx5KG!>Ae6-#E zf2*E`cRAb7q>s{(esg|rc%v-x?EdGD^!f$G-NiBu6Hj!W?b@|@Qf&18Nuq`7)#|SI zcFIMc+g7c&U+{Ip?8oo(=el+-jd;fL^XAhX`>SPV%l^%ny5?2e(={h1-3;6wZuI(9 z*e2z?iSx8Hw?*19E@u6G>xtlzX|*33E?-g*-}ohArB6EN74vDIQcK=E6!Lg%SAO)k zzi^#N@;<}upZsTTI{b$>{fzYa56w$19o4<^r**b!g8Z_B21_$mT%S>SBlpn+?&tZU z%xiUhjSDYaxw5%{_2VV!`Of=3n|p6>S^DtSS{4m~WtIXu_5nPiKPsLpdz{f@t-n)U zbJEuC>(>)n5>KNEEW)MdJ|S&}EYdjz(rR{hTI@0WLSswq9- z+ug#V?s&Y$O6Ox0!*+eM?rFSp<=(ASX?*uO!TcQC?~f`cSMSdKz2w2&qk8h~O_TX@ z{VuzCe9)08@;M=LzNJy=3TtV?i!TEEit=u6w7C2D+s2%N4WIu-U%FTQJBsJlro@Ye ze7Q-{?6;aGajy3N;4Epl@r=NXkES9M?YcS>Zaq2wxvS#nwpqyezx z>ZMh-VMV&Z{m|pf9xvRnVN%)wv#yTXUl~CGyFWB`-koE_BHO+tIi*Fw`isQg-5m$x zAHIB>_b=^%CYNwSK>O>Jlg*x$CzaZqG+!7a5a89oz#zV;cS)m}J8#~igwLxy_QVSx z>~7(TopSA7#ve0n=-+uHK#QXp`}s!=7QknA7zq zx3s@0T7DFtBy2r#PR5eVjYm9}EUgqcDt!1l7q1J4#|ej$E~#WTlY=uE+B079Ob`6s z{_uK}Ql4*J>=DhZ?HB5}q9)ylOM7l`VGi@-t4vSc?BzdUSs=l%XI@KJv&Pf=r)T-E zebn)6`9eJ|cKc7#787?(Q{!dWV0@78v6`Fi;(3eZ7ODB>KAhwf=NmsoLbFp{@p-hV zaFJk;O@EVxyWfY!hWY!JZFjg9J>#+NB+0cAT)_^aJH$RrT=@FJ*B5Lh%DYzHJ^NBq z()wksfDO<2%zg&Bw_hw}{Qc$kZmv0RwuWK;r8CQ4wTFIc{j)Esb@}J>1@3zi_T7=N z`Tpmw+5Den#;#SlVP$J7zA~)(bkecHdBLq{<~0YhSJ}5rdfhN7qx9RKwzRq%w_b1B z$5xa4fp<^*M`MG3B1S4b3!Qq|7!m};sDvU&wSX@+| zbg{?(aD7EYCLLz4VuH$!gWZQ=?xyeqQzE z>7*M)=^;uHI?mq4iwxJF>f8BZk50(7uSd9ochRrWL-|M^l!Kjs#_TKj0e-FJ^Rk`~^TD`b>m!4^pY6NE-|6yt7ko?8J z?&RKG;kxtAuAn80jenHQoXqwh^YGsKD>9Q~=N&FyHOqrRykB;|%mLrVOYa`?zFncl z!f4id{D*qg_ZuG5nJO%8r>_yqwB579q?5b+^@NMF_FsIv@v}trs;g18UJI^wyPp3W zA~|9CyZV#imaN?ITePn_m_4r5P1)YX^VmdP`nGZOd_(W8_8-me&cA+jyN&v!32$fi zeo1-W8hN2MT%vcgiY)Kys+xe}XB*Bk7`@d$`Q+cVJ4esHk2^VQTFjg5U!K1&ZZ7k8 zJnE2I;&$K1?BIdwhW`gxq7>E&Kar}n_H1K6=i@PhdxlT%;tTVAk1W=Y$T}eDAHShv z$JdV>%1Zkttb6?-uJk}TbJILkNu>xEEq3M`Q+6_>XxTXYWt&)Vpdz~ITA$+-i6hq) zQ=@m;pIT+r;*#7TH}87$B#F8{yZ7v-roDncmuc(#=gHdag)3g0Vh#lu~4&3yl3ZMNHbYdL1!d*WcdzG2_7J6$V|JrTzJ}C+8NiY&iCdVRpf*jlauzI`{T1&(@XYJAX=E=3(g^o+~Wc`uCQ9JK25k zOJ``l>$iBr7>N*(wkEhd&yYdPgg%*9};#zxmoPRS|y^Zx89^lT6-?* z+%-NWcUPU!$=4MKOF7agbX*x?v#IjWwbT*X-h<|&rr?q{` j&gS-MC+}>M)C|q{Yx+0e^KDJ%-CEe`AoJnYieOT;hSE>pR)c2y9MkCIqH@{7BCL5!<( zT>>3<#eDhy-;>kJWt;!5yXG&sm_q|oI3iiPQ>-F{+q3+YSSax1D9Ie{#oSQ$yyDW@`P+Bj%yHsSOjxj6 zM3F_-;u zn~B2fk2zhr;@TfFwpQ<-tKlKyyz0+)_8oWLzg@}Xy-Z8?ycm6io>@14hrQrd^dT1Vt0Amv8o5Ujyt#a>1X>a z30d_=d1t^Wkv6*^%^;Pf6Pp4YSE&4rm|Q&ds88RHcN{%w0qS=+w{G^FyjW(DRhY%T zvqigZGcRx#iTiqY@9RCUSJ%&5zD)D#=DBSBt$HFh=e~WrwD0c1HznJYpIll{sCJk+ zfR?|%;8xN^_<4DS>#=8u)?3mD26JbDym zIKoX-js=}4(pKDmvh9g>L(T@qO?S&q_}eSi{!&=%rucXnhu$M5uGt$JlY(|QDROd( z6gM+C9ylb!yZc~o+OdYG%Hd zyjFj4MvCDUXKLxz64#?E3ogvuHF?X7TMv}33aN4FMODeK{{JFdg5&k_7q1UB*gO7j zT@h)|oV~9h>-(>+jIVFxV;Sv3xTHTYT>j0)(vVTZ&pYQJTb!&0`xh~;-M3|?9*Iaw z()4_9d?s&O2LFei#nZ~JUfY?)xuoj2f+E8^Ybm33sF&8M=X!C9TyzI@d zQ`z6jTJKKkPwd<};egoZuI7sW+fr*hEMCm|T;qB!EOz}zhQ_eNVP((eov++pe0jCs zn^`wM_}|eve*EcF{yW_wlkRMsADPNAL#S=5R&&WEbM1hiGIwP!|5}m1?e&qLhkkSY zzT|E_Z|&lB=iWZL7jw6$x#5YDQsDG@jryt|JS@kqoDz)OpjOgo#Io+Kw#&`c>1SrW z=2Yd@<2-cFGX6>CY9X%Ir5kH>FSgH!SmKp(d~fH=xR~haDGzogEj=D^#~?13^Ebz& zReO7iY~RkxyPW6~yNRnY_F#&whx~zmwiWYJcfTmkxFN}XJHy7erR7$^rWMclzrR_r z)@kqOUp>2!bGox>+rH!r|LVm{lO*9J2yq^}?7lz*_~Hd|1A&krZxxyO3LHHy|6 zp7=CNMziCIj80PSiwD;~^gQdT7I4_svx&QK(Y9yq;pdJWZJ4T`!q?+-xJ+7ZYef3Q zOY9-B92Yj{MHSt@+JE-)t-cV=jgs{*sw6WfHSAxZ@pV^(uEe_D#5tSh8qTe0yb@Nn z=h!^{zrQOLMJ>K4nKX&zIi}7Fa@?=hF3V!#eskS(FZ;W@%s%u?3o5uZ({FOqPumwy zKJyoDoOPgz(|a=e4W$FFyT=O%dEI)iSN#1tjIj8@V@Mwt~Kc^i3|JPo*zA2ed zmF#*s>=H-FfQ~er7FO?7X?mzDKj(^nQ+U zVW_sg^jMW^w_oVq-aA+4tXmx>$F_IMYsW{o&VP|CIm?~2b<dNPtyPG-gv{uhJeCO%+318p3seu?&k&pL}*(fcB>Qj_opKH$|^c+3{}K?o)Ta zA8_suS+V5eikrpyTPs{&^RK;^b=}c@eem?}c0XQBKEJE><5lzfUtgG}s92m3D4Zzx zsI~CJVHKZbryFgx7A*#UoaFu)n%W#P{=>xHB3Qy<;lAg?k!q%k$veE9lpE(Mm?-H> z^e0X`9P+5*23y|4d*5FgO)RimoO(lFvgW&ip2FgUr5pi8~bK1?f>)0)~`~T;jJXRu6FWkUy&)C$$ zA1=Lw45eKxGKj+PZ2K38|IhV6>!l8jY`_Sbxz_0^8=zSU+v(@Af}AwdZb zACc7^;?^vNjXE{!B99&O$(|W&qbX`V)g-wsQX{}%LqRg5=(W&GEfRCqEQtJdK-t!9 zUWTtv$ORYsxOq+Q^>_+c*o1_)=6z+ecR3f|;NaM#*=6+P$V~3=ygQX2eo5Ol9Cvl{ zGiX_`fa~?cjWHAdy-E*5JOgMEtG-S1=?}4LF zPcy}9|M{Dr2alpvG8QkU7jNPg=<3+rkYIle6;J$%Epymg$rFgCf(U$vG=CU zGUuOf!alFiUsB|xYj^S0+IKluKX-7fTDAMsUE>LhCP@3WS|rLzpIvhidp5i~Yb3nm^>>H5DcKt3-{s^C)mHfmTPbWjsr*f{ z`Op;EYCaj?M@MFU@K_>uGrQ1v$%DEFhHK)r*H*l-K62);mfzXw8atOPO*t`TYU$eb z`^BW!<|J+0+{(q@wQBB^-A51KJ6UmTkBeROwNG!C-QcsV`ZMo_gwi$UDfKhOcZaei zoa(b>?^7#$74=f9p8V=lh?V29n2dmn0&p9pK& zKUbdPrr*lqAQQ0r+pmKsc_j;POq6L&K3r+vdU56rlXdHBzN`7^^*l^q2?*nP8(W>R z>_{uasy$i~^Ox0#KRR{8(c-|m9GRV5DY5%Q=ButMNmc^dRDBn8&M)$5&GGU94754h-f3voJJm>qeRo>^{XvM7mewWw&@6&zz*4bZL zZ*OK&>fgi0>F>Wr|Y5HaWhY7b<=eQvdCn zb9Sb{p=X8dkD8RbBMOeT&8oSp>iy;(uV8AT!OYeTx4dsENSyhQxZt-_&kY%8qncf2 ze|`vaq;&8~b1qDI($@4QRm`U@A^EV8k9O=T`DKDNpEhkQAApJK;Ht1q7|cB$Wp z-1MH|&5CSR9j^Jgi#F(5U+VLD!Txd2>Vt)eh4NNR%-7oj6V5$t)IK0OUue4XBGzp> zxu!SNg)IupCp~^%xphYHLxlyp9)ib&ewe-$c9#Emr+7x;j>(hsuby!Fc7k)OJIA$q zl57uK)E~DT6?mK{xNL{~{GHZEgr%j;9=V_M_<6cxmw@#zJI?&LmW4K9xAydXS)yRS3P%&%6G*=wmbNp`5X2;_5JHCW|_;b(G zdy>QN13#98yr?WVx60>5{fq6)i~H}K60p}O)u=6gwhd*x$ac{;q^nFXK^{Evsr@ zutYy#kYN_?W^{JznK|P?539BULldunhy#Zslb{rfKwz!X$q83wqI(^5Uwx?l{Nk># z_}jp?z=t9yw3t>E^1s{E$JpQUUYX}-^0X!XwvrzXKUtaH$oOgf(XE@4r#+oHbJEP6 z8s<+I)~J;~k!S5#>>APJm9kJIO;g8r>2jax+)sU9>K&eXdco@OxpAJR3&pNYvx?Qf zy3Xp3&+IL(TOLfimLaOF{vaS{`9f^QX6ecsf2XNzi2kuzzPEcrpw)Z#=YOOwXc+!bjpf_xzc^(JyByoG z>emjlBP@3R5|Qm`NPGEp&uZzda_!3-`=71LtBhgu5?Cy=>g3~d2iXmdd1`Ds9&0Tx zsnV0Qb!LOJL&v&*W5O)Y+%m3yXRjc-;aif_}y+>Ep$C& z)nBI{WVe3N5$_6qv+R5PDaYl1Za+AE^y&1OE~R=&mK${R@B02= ztcl#e=1YoW(&|v}SBE0~)&?HYVAoA`{{A!Ui!=9et0%cNq2JF0T@CP6hUjj-#?cgs9xt6np*pxtMz2*bhq63`@9Z6iSk*X86ICXNiR4f z_iC!&%Ba?}n}nnOC}6bE)|yy$=2^_U_ia(pjym z_oTF@OP`xm(9~CQbm7&+OV_oP;=I@Lce79Lp7dMzXzUB~qs_= zdcKESvSfK#-skxIJy+`r+U;NNtNlEmef z>vXY}?73gfZv1$wyFK8d`Gz@Krosl5tG3-r+bvsmD|Nf=w_D4@=2r0v^z`l&_&jfI z6Gx3n^XmL?dtbm z?IYxFf4OP(vt<62qv;<-*yjB{yy4@Ib(_Q{c0^CrNYj6kq#3x{m4DvP+K~0B&9`bB z0_}O0aWB2t`AMmD!LmtCB`QVLdfE$nKCND{@f+KV^8KvGu701iXIsgXy}K@2eapk<%|Zj_#HB+oVPLzAJ3<%)o*YIECste@^QtBU_B*`4%p^Eu7TPlrD7$!kU#?ygGxiIik*Lv zL##b@*4Nk-l@f=o9=1qd<5Z7X^W$sj^~(ycl0VP;xp@9%_Sy9>zAmenqVqq0{f&9= z=HFxS5;5MrV%JP-qv~g;?xttwUYD2HQ+D;2uJw1`;DjxYyt-o}zZSmA-qU(+>DtBq zUrl;8$oM8lmt*t7&8lrY^EEH0o||Z{(a|F0tfk|%Vri>f#hJ}M+;3|hus5sqT5WlE zVQcvRBc7A;nk1h^CoX==XYq2;*?nQ@+xPz4KW%x}*4f+M&n$1za6PKCd(o=wzS|y~ z=e|tMfAy;RZ*$+OE6Y{qzT{RG@SS6lllrRgi{$nPGHdzffBEq~$s~VPTF$m-_l)Zb zjxAvqUw3QE=L;{F+uaZQ|9ao5{7Qr3nY(SCEAL+@z_Pe|vR3p$uCGxAyfe2X-1N>Z zvHQ7j{`PH=xz5pBW39JFS8u()qUGSr$42QXOYgHZX%-4M?P=h%m1^2@kk9r{fZel& z$K`J2l+9q4NvP?WD$zdgoYB5V7X;^Z#c$kyd&lvL3H)3CE8L&sf1RyCKJQQLze0!h zekP+sNt^zboPTD`wNh(?P}r)xD@)`{e|?xDv@~zU-rru;Uh9K=Gtwm6c1f~JKK*iT z-zQnMV%Pq!du!q{+5_(NvjrSF6)VwLu$HUP!$~CQn?GNFhxAgZS9M`^du<)e%6ESH zakyZ~mtZ}PgRfZgb|o)-6*q51uU`h zJ>BE<{kJd4WT^JB*2uDJz*Z&-YC*FK*if4za%IY)G-t?srJ8KIJ4@%K13 zHaZ!n6)$g%5S=eC%>2SpLgc}W?jHUKF5cTedtd9Mw`Wf&zH4}S=Ch8Hy}MF&gxyh< zN#DL>@e?(_71u-eId(ey`)|}Fm{)P0TjI0|Cr_WkcH3h2_bLBV|7<^+o)9vnz0%D! zQfktYRjafZ7?hNZK4~q!F<&>kC+DA4I``|!V@94#8&lLgC$TUvNXYEH^Y4so?Xfc* z2erSiS#$HDq=e|x#Cd^%+h!PO@huf!wQ3auLq~_l&ROP(AwDx^&YY;Sa@8s>hK7!g zDR-mnr4}bi85T#oLCf^e`v)F17l-l&!EsyQ3eJ6D4e@BiNTu&S7U&Hq`ZzrW7U4?K8%)uZ@{ODEPG;wqhF9cuIiZ9hf`Mq74EWg(%<`^|M!w7=h^~YmhZe|>7G%1tLnUMhJER2sfTOk zXW#zabF|mBd{&v4)zM#%8SLv;)=l3RvEO;W|Nnsf?>=rlo-XIVNBeqc5~F}+fx4A| zeqY-0xZd=txkp50J{)lUxiEYG?>>uF-rZcPTCrhE(~jJKH=WOtGcob;1Ea(&ri1Hk zHFw9CY>KJ>aruwxa&7J6Gp5H&FSU2f7w}3vq zu!~_r%W;YMmfX|MB-={$G_B`{BDPG_2{lP`{HeQ)HL zTw=vU#VH!uoYyWiEayDMPvb=jf2iZj zHH8lkLsM6=EYhhB-;%X&P1c%J_LG_3OgY-w&)5@=$H*`X1?g#K>^UAMo~bL>Yj^JO z&Np9OMMeK--8_H(gh5a8`kemK+4JrCj$Aol=F(c2hQ&ch%Px%?jW&J0IY$x4!rAt;<1uf;(4oAJgYGUCgN{C8H}&;zHU#WQ)2;vQl^t`PDV95>`pvhAa`QNQmR;B> zemeE#r>AmPB37LLJMaCr3U2qj^QkqVJk@X4EIq59p!I9B`?A(#ZT2?>vm%xKI^Wm_9jX%xHs>}dWH|X) zczyS(!lZT64$V%`RyuXc!#%a>u)x-fjx*nN{fX#)vTf1&@cBLG7p3I+p4+T;#ZLa$ zhK{T?ih)NW))d;VZAs=^sJE*qW6SMsg&j4VPcu^!PISe7HN4_~RykGw>(+THd9!c% z9~KB)YB!DNn!?-%iqBMvJ#-VY&To0?;U@9-X<)v~>!js&Z8khVFY{gd^P`lRr&{%D z!VJL~iX~bntdKpd1koc!K=a|%5 zXYnomtN9g;e|0cSn%WiKdvw>zw@*(zyEq|L+{vWdXl>SpJw?3x>tc^~YZuoYoc_F~ zw6gHYs*|<`o$MZI$2*)C8y#uoV7xfZc9Y1YW6$6IoN1Pr9Kl^(|c(dnI+t!_#vvc_kkV zd$pl+>365AQSY`?M6Fsr+jgJK!kFOpwQ9~Rw(Q}D_KSP#*}h+A^!)yHhu$>}d{dUn z?N%3-XjfjSy>D7=z$D1cJA|nsux; z=;;mdhi_AgY(Fvyn^f=Qm(P2e#9&FHdIf2@%`r;+*{FSz3_F$(0AD2^$CS-7FW`Rkukk_=TgmjOM@p%MQbLH+N-vxmBWd;hSZ1oXyz8mlQ*>#7^f~+?w#bO3dPlE$*@?N=M>34JTp&Pkv(RWi7 zxA!ht+g!C#b94VwE<(Va7F{eWW zw@jZ{JY&(_?+P;?&#U$OwJb*b`KBAZab6NLO}C|exP0kFTfgO-%{N~1CUQzSsV!Eo zth`fx#K8E&VwLaEZ%hW5w>Kb3x;znrFD_IJba+Vw&@PoGz< znieVP^fs+)-GaRL<-+Bkmqn`_KB{eW=4`jvv!_nUA5xY3XCLSjm}q%nexIwT$fAM> zO-J#BjW24nkDA>Mr(->kYN03w_q;{M=_D#&F`esoUxI zi>9*P=QtF+;6d{4BG;eK-NY(a%ybN!!Y^(!b=v0*Un9QqFJ9IE-Bny}?u+wFgKi&l z=9~Bb%Ap5~oL3(za{Ik}=L9jo?Grc`n@w@&Q*?IU-dFUe{?DTy+I;-M?LMcKonEJ| ze3lRw|MB_7B}EbID_+m| zC*O~gIZF(y#4e~^DrXB?#r2}ri8toq*r_cVs?c&FG^6g!9UluL3`JcW2Xqa)t z^!l9K|Np<0|0`ROAl$gh>U8KrJIU2I?}V}YH!`IhyU%01;DFBSe6bbA78Z%Mg(urq z^Ij~~+4s)3{crg}ZV6^@->$B!pPM&D>Pk1O)vY)q^!&gDr8&{jTnkqh9cHpDTYO;2 z;*%+8V~`?1xUp9@wFb;zxb^Ypv$eiivbw)6h0%1nYoSZ5vl{#5(rr|V~xZw6~bP2AsJoYIv2?q2I= zwUDEy_w$G!(V5EgQKit`JZ>Ss{B(ip3^l3gv3uWCDZCfbTRKh5#b54I*WI5dAI@If zb>QtqVWqsQ;p=Y7&ikc)fm7-9>_>?l3C|loEL-Wv(k+?5#B?5|oyIIdwU7 zv*4NS^}pLM_$7q0Xvx0n*i|xDT;WQSsMc~;j@%O$Vi^;484?-0uS~Yy_9~R)=INRR zsjFC*hR@;HRrbhHXT{(1UKUH@{@N{jk;QDiX7}`}yBcnbB6h{!OP@c}Bd+x6!?wm6 z)1FIpi!U8*yuTvolIY)$N1jOA)^EuxIH!Kxzv9Na^|A{mMKw+PpRO}GHF@bA&PSqZ z6&kR-yVC#&YsTta>Y^poI8AaufNJCcda+uka^d3p?S}u z?IPaawST-Z6;2Efmt7}+Cit+|_x=l=jr&&^wO1X@`px`u)xxIp>or&X{3^2F`Ec-p z0}qzxXq#pm>?jH=?(_cpbF+3xZQ{0^7MW>BZ*Jpz{QPsgTXzxnaV?Mgv!s~qd#B4K zedcPqc2HW)E-dB+fBoOQ8T`kPrT>r~4Q zYHqUdeR*r!_8``T6G1btM!ewp`*X7Q)j6|Q%5W`9;XVD&gn?_zHC>A>YZ5md-yahv z_|n^2J)?Gu!utymnGcp9Tecwov1r4cfLgi2ER~YlT$MxbYNUU6-#@vsV(n{9Z-0xd zoY`j=%=K)W5m{Nc)c?T3$i!by+C86|p1=HWN7T+kY`M;_L>W%5J{7e?YL4KH&7W(3 zsEe~7*%uLHBHQA9;LEW`nhz(eOWu{z@rIu#x6Ja!_YK80>t))@5^n6T30l0K{p(xl z>1!;lgDqubW|{8!y4-An)K~t0QA@m4jz9l&$>V%fKigjUv*)c%!i0V-T&DQqX9eF1 ztI$wM23D*6PbSRd=kMRT=IN%yk5dmOynpcKqDG#>2ZlN9LOcyDhS&L5ShTe-XA4-- zu-yGo%%X1wZy(fg&5P`;I`Q5wD)`RX-5b|(Jms}p$=Mcokn>_vUdETn_m}?&@;%)3 z()j%M%$Bz-To!w8guSiSU-#DU9eZf`i^)&!KUlT@$+y(|t21(Tg=J1@H8>ry{#l&3 z=pv8n>(#5hgIC|KNHzcZKTS6G#HZB?LVbqET&63(p1I6^wWQ6rd2>&1Z{WMz{JEFM zk71>nklH=oNGaamJM5(LO0E~XT(j?M3%&C%)GXBbPuS|Evx7@hpI$EYxPGNC{a4A^ zDeay;s~2oNf163k{9@*%DYI8B{vf?KZ1vR#<-gup+Vy8Wj4=rK7G=Nv?6u`yW%4>( z_1V)}Z@r%!8}oR|+5OjY?YGKFS2w48O;y+ZT;BUDye00L*U^P@?=e}3{#5APt2y_` zgtRF62cNr_etY=tiBEQsU%pd=zmjG2&+ebTFR!M{s_JgvzWn^B-8L6?c}VB@zwomP zF`gs%K+d%{RA@uSMnJe>R3 za}T2d+h1P}3sL2@4e2M&Dl2}oIMPz(d;=rp$>>c=IK-4b>DAPB#qwE=yJPmvUo`CiAJc7XxhDtUS5W*=NX)ubTl^!d73M?Y%YbE|FgEcf12d%xc_MD~&Fr~0@_ z{q;scEJD2r4V*8yD`fIExTTITbw8FF5*6b9^&JzE*y*yMM1_glUeNbam31&;9V)|Gps7>F7S4B%)1lISG^Tn zoK&nWyP>Z4#REQtyGDHbdW>6MPQNSrN;CYi&HA=J-z9%ep8TA7i^qT-~Ma;X8(T6BAyKuN&DG0@9)3o{PV-Rgnj$# z`~Mx>{kB@wz~Fz#nID~kA~kjcQA7aoObNsGhPPPG>5ne6}#iV&*#^9 zd0hXv;cB-#QOqwDx5i4i#ke=ryU#fnvwxA}<<7~wrFOUMp7i1Sy6*FG>)%h!6JqXG zzZO2_>E`;DoncOmg+G(?3SVyCH}PwtV9&E+M$zVaw#jT8uDseHP!lO^HOcIOT&#wJ znZTP_e{+NE_Aj|~~Ys>tO8Th57xz3;0@6Z0}&c9i2tCW_B z#OmC+=a!UqQ$5o!d&_Z&R}1&uxzoo!Eg|-Z`mPt}lEZ%Sb*{MfS?g5ko|UpwmFq=s z_kJh!3Mn(y^(WQ`%Rmiab?yoZt;aX z(}S1)`Eh4m*F9eg#<|Az*;`d6Jz2K;aaf{o$ytsa*Vp_xIrCtxW_*}Gp_J85qA zyttfCCPBwfZHbWd9HGnKR{2eSFg`Jo0cvqDSm0yYPr#J52u z{zI5kV!GgnG7v;Saw;6!v^{;{uh_Y^1nbm6#ZbJzHQ`Sv4M z&o3{j4_H{RWZ{R1@BBaK%*))l=-7H$PCc8jOa`I+QyzE9r|5gGGhms1=u4_L-|SUq zR>oTX-(y!<@!*@n`UxpF9XSpCKP&y(BgVrTGERg)bT9E0@-q(HFkNa#OucE@^3i*@kqi?UM+$7jPpM~dh!OQ36 z;^%*Vn0oVj(~a+k!YuDI+x=TqD$W6EmE!SQ+h z>%DX5EN*ivPAlBKbdO2w{H$n4&-}k8DQO%>nuSyT_qP3fTYW|@wzT-|-t-l>>yO|4 zeeOfv)lS3O(`~!bb**+Uwwu2?>*?{&+uwCA&v*1rIdYk8jS%Z*mh+S59Jv84mFXpd*-`S}Dkq3l? zZiakOe|T=4++nflSJ|hZvA6Qz(zu~3xGsFw!h^+oB&W{2A=Ev)S;eC`Ci6`5XJMB_ zXGf7c_StWv;+Y<7kyBo6YT|5H|JJCnY4hgz`G5b^ZhLwqWZTit`oDJmO^$xIdHYVW z)|k`j_cxus^xf>S*i>VIFHN?WHU;llzSw)walOMQ0=hT3ytmVRdoQq6)_L)x*}G=% zFE7}wR60?7OR$WT$p1}&`6a*kI8=hZ*koJCf0pB3-%Vd!8=5@yuuIpE=2F?)KQnd#S@Dz{ggTrU|<3%)G*Qu%l(tGMpAgqaE* z`%-j&y`As<=KHplom&)t&GoV3y=E)G`Zmq!;J zFS(sQ`Q3Q~??5fLR}HHdJWqCQpL)7FZwFV0c;`{)oo{-C)*Hzdo)Ar7)1RF9VB4Gb zH~!qr@R;AeVlw9`&ON;H5?6klUj3})iOcF!yN?
    @N^}JpG&x!!cq1nxCJ0b7cLV zNNw=D-(;wg?ZcMxX0BHAVMEESTocyrRBm!?c$OkLKQtld1e;>Hr%%$IbxNJ-jVdj+ z@>7o74HK|Dny0_%S%3YHO?T(X%%3m*Px+h84fP;z7yT(c=|&$V(l(j-_#JyNd8Sj- zG|sG#W<58F`mJKscpw!rQ^N-PKi%XIea;iiMf#f`68jry+39M zsB7B?t4y18Z2GV81cP@EYf_vl^c82lNqU^!^JU@Dj~gA_j6?1IWh@C`x&rHo-boTfWlmF);&%V2G;l-Jz(2VIq zrM+wVuKsq6@LkNTmtnc}s8#6Y_xF6m8$ZvLT(!OK>6deSPrvhhHwv{{sO$Xn)5?^F z<=bX`eY{sY$^59+TDki*L7$&&jZ5P>a8t@p$n>_kbjenyo7y*TZol#7jp$?5%F~Cv zK8a};&$m8qb>p~|Wv0rtdp9-b{k^@6dB%*QFpd=rF#$WeAG_bYR4(i-+%MMGf5SU% zg8B8-q}*xe;yk)}H*NfI?|u5joHvh`Ej$ren2{V>aQyeF{xzj*nRhR++%>84e_q&G z;RpBstyTJYZ*8-wKkJhJx8&!}Jm=}}`z*z6xuNyt;LBEyPgphf-ROQYf7^jYXD3K( zSt`kvn%4QTZS8tLnZHT@+tk+?uGh8M_wx(uy{q~$RXIj8YZQ1ptS&ei%1af^HceYT z+mxqT&dOViF<=3Qh{KlEdz_rAeD$=1GN0wAJQNUmRB86zt*Av>_13zuhEvabHN6&F zoj6@-S@G?%bk@20g`uUNpUT8#hbL_p-~1!Pz1exLtjED`m)~fcv`MQT+tz14<^1W@ z7QN5Z^xr(`c-xckJig{|PDZr$iCXQb&rxaf<*$YIS1vU0Io^Bj`Jw(!uP+z7BaRqv zF$}q3$JZaR%vWAEf6qP^5#EZ`U%x$LWUS^qR$Z^-X!QZNEa}hG~9_Rkv?9fHpx?I;yxwC{JJ`&d_Bc|31vlk|1FOE?(V!>vC87(89Ae> zww3?h9r1`hpJH>+-caD{im!(s9Xj+mcQ*+7OIGOLT#z*z1X$C4sxt3~oWjvX3 zIK1G2xxH%5#;@fa|2KVDyOa6zY^E9WzrS&ew8|Eg4?pf-srO(`*WtiTi#h-8Gz=*| zHm_4OHv9MP&(WskTyF!T7c6G){37O7^XNdOmW21@Uu(FE1E#NXpP^LFFE3eFDHHhT z7vtTF#VwvQ4-~$S*tS02OVc^bBy`)h zMkgQby?tljmvLM+F2DVFlC#?F*4H;H-V~qWS+w`aQNf(Ik3LxJ&)73JmxuAn=Z`6N zrt%VT^6H{4N38z4LsMW+4zqM5PWOG%yef6<-$?>}p7wK^$$;nf-mwba6CkpU)CWojl#9_ERhlO`rNYhzTt z+;M>oOTQG9XeO@zrk8r5#yc=RFJ^vOk=f49J0dbVv)Z;xskt1MxxU<_sZCpS?w++b zZ+%-i{eIjgw>YMc&r*Z4^5xW`eq26p%0GG53*8Cj?4QrR^p4icpJ)_yrT?C*LD9^c~?xbnZ{PeIVLZ-1qWByC;Jc_edA z+WE0F;K#b(69uLp+{Bi;-rf0FSM&9bZBz5F$C;YVnjo->tz}-9);{?i(TBcn%VZFc zKYYjk#P2^ZKc`>r`TxH{&*D|vcBN$lman^b0!}b%-#BXnY35bpN+q}<6o-_ zoqn_M?Kt=lj)kz;Q5(dl|Gx1PIq|9{r)Z$5U%=L+p|&pG*a+16$3 zZ!WQhG;9ddDNH)^h&wfRN(aB3vC4}>A1cpDa2h?XoMhk_W$*i6@9{sL9Ko)K>Tlyu z&G=9x(XmC%I`(WU$7`tsaVH1$#g}b!<=((%C)hrtFsF`4{?RU9GpBos_ak zT_bU&#z9twizoMOaFb7)|H*iF;T4^Yx&2q1zIcmIvWe5&d}v}D65JqL?RELbbzFqF0|8LdN)7f~qtG{6Wlsv1>k85}yZ@tViq3e*h(Uq=6 zlXuROz8CwY{Nw?>hg_vcR`=}j)9YtHyX)}jwfvk8Wrs`c+RfznbA@iz?7T4T?}T3u z7ar<d++=o zrmymz-0Q7xt?hrdU-5pp-G4sSo59~RgHH2?JZtO@S=PTKE;9VD@6r7X*)xBv3C+B) z@Y(-{(iK-eex75(XgmFmWk~T8Gj)B-_og4ho5Ow-ZQ;&Y&%F0T)F0{JALQR0XOuaj zD3d40so;3I{vb~r`?C*j8ydbFduz_t?=iX2-)zyNd@*io0k6IkO2$tvVFgVCm8>F?Q@lT`~I0{V{Mf$sHjb1_GkT-%)qvo$vL2Np~SI6 zpAT*5P@Mgxuh^ee-Ev#|mtSmOzH}X%rMKQyi0MMg*WPCN)8F?AR0@3gx%$hT!UM|N z7|faeI&w=zU%8@jZ|3CP)emp16uz?m$YF-dr<-eLwch(vPxRA#rg!Ps9H}rak^=(slLQ zeK6$EViv5em~ysb8$sNOUA8+8w%fcT3;-1_$zvMqwF@Ry{{(kb9w$; z!G7l^uHQUs3M>y>`0Bnbowdj9$Wq1+-&RdN-80WFG&=Rf&m83sPZ@S7HNANA>3Nvv zp|$ao&+t1-Wyjtw+VCNut64K{*1cV;be6@Ktn=p=UbsX1df?Tq$9XTC-D^{nHlCII zoIUo}rJ_}PK1Hn22~y0fUa(?GBIoT%E*|ffM5x`D+pBu}(I3{65h&A8;y&zkhx zeyQO@o@a**`s>aI+B}HP{P^YU_23CCYear8eK_Z2apC==YsII{nwi3|LVo&vk=uf* zOBw$8UQSXz^3wUDX8x1?ZatMILcumK=1)BD?62zY6z8*%`C3!uM&P+X2n6aB26s12apM z*u1n@cW}zRxV-&TmKMLPm}f0vdElMWt6Y;R!;c(4 zp!Lt;Zp3qgVzo==C3Z)OcWiyV}Z(aJ^fWEfPi|%p9`c3>UF>$YlhFp|V!9vTMA`vEXOKdOS zY+AYVdUw>Tf)ApZ0b96d?hg(A)z94#nKtdfEFQ)BTU*UGHZNQAwdV7N<+2Cjth{Td zJndUl<|tghAi_7wUMpvPshIKR=uWjK)@5o1j{g>!6)loJ^vR&{WJ=8I=&jpyUYRXj z8NW%_z11MEX8+|gFRbURR6Dm_IBU`CZ%d@L^UO`Q8%~ef@b&FxDoNqkZ;1yZSwr@?)(!>UmPU1X>oe| z;<^0P=N8MX)fa!nx5aqbuDcW0CztWP>BG*g0ilW-Yd`H+%z6K6#N;@>sLQuxbDr@% zD_QN;-c%G8RjIUbN!MvDp{|`%nzk-6I3l)W%Oh2@{UUJ%&qVb5VP@ zA=9j4|ID2}b=maTP5aL7Xi;6BwE6P)S@TPFG``4=Ym^H)yya@|-rY?b_D|aD7qzs~ z$L|}r;TvrSo%tM(rZY@ie(YK-@0!hixr|-~A0yK=az1o8NzHU#T(ziws#oowcbmW4 zNgvQ{U?>c4FIZt<8gcc<)wf#B&aJEuW-82SpK;s$22b4F_Q^Z;M<&TN_w#@Dc$R3{ ze_Gq-*~bmZH)E3L1f)(7ELhvHm3Q^blg}o&{+r4>)o-c8vZ>Re6{XKMe_8C@&6%*$ z+;>)v`^>3}{M2K;f<15gb;v9h4Xj+IW1`3{9>m)>C%Sxk^vuGCmO`E?p4+=-KXr?I z6L)govTlL1LSBBF>3xf&b<6!$sjhkM>2zwY{Qs6`CbN{)HSX?_NNT;YIZiWH>X!7@ zi4|X_eR!7F*S|FNt4vdA^ce$(7nEurNgU?t-T~cf46|HaUC6;>vq(cFnpx ZDJ%-CEn@5nJnYgZ7qj2kwk4s=+Cb>%yQgY*B&u#$u*nsx z3AwUn#@+n?Uy{@4*oB%o)7cl^S~ojD=__){Qv$1Yg+H1UVe#kQqP}107V*)GT2H>*cUUco?{406o%0(zfB$(du*-Al zxAQU!H~JSRbyLEx zqr6__yDrO`-*t34;wK|*e$_x`|C8H0nvMHh3xlO~OF!*jxm3b*{~w0D*Ya7OeTxpQ z55JR|nR!V62*{YDEh9ad-jXctkQnno=w*I zUVpx4|Ic6VY+TNyVZWp%j`jVANzZ%@9=pE=8G!|iv<9vgmZ z2)*IDzR`7^cI>2j7VB@yH#VM8R1$Kkkn|9kcs+G`2aEBB*BnAe^f;B=1wI>nkUuy- zZhGve=_O%72mF?7l(kqPeYmKjFTrHj@9pKgQ`T3nVp#qB-A>i6T8A$M`R_t~7RPBc zuuo!&(EMUJjemwy5Ni|rj92WF%nO*W&B!QRH)(G6de+; zs{p_7hairRTT6Cne7JE)aP9K1orx!(e*1ByHu{(FgTlSG>$b<5*DrW_;IhsGg9ibd z(i~?sm=(P0xtza?!B6&6rv8>U`T3LH#FmH7^VX*v+4FyHn`A$)E>qHc(aC&np={ZW z@Q_X!2Ax^2`~Ls=`D^j|^_^~$5|1grk6O*_KWDc`m_w1rmQ5#iw=q=mPk6FG=8GJ| zr`L_vTdQO1V}hdNL*p(7mD~I=|C?4^K3%PSmHWkzmY5c%=aUxN6+M#lDt3N7aZ8$w zQ_}v&r&)Pz{_nr`YENzPbydGbA>65%xkvxkoq3jXt0hC2S9FnkXN%j?OWf@jjBM`T z>aeSc6Ec(xD!VH!aQM>GGv7nM%BmPR`1x6x;p=jUCX z9Eu(S6CIm6P6!-2`mSC!Dg5Z&>y2wqT|V*j@_*@o1~Z093@s9hrmRk;S}o;AoGawn zo#ygz#Gh>4aKY(_GrwRno1mTDkqwh32x)q_s7rMoS)w#?nu$u55ib+t+Llho={9kp zy|MX%>GdtKCqC+*|HGltGKEP%;bCB-CbKVRN8@)!u?@WbvwzRroTC!DLd_|*HvEHC zn8Z@a{fC^KZJlyg-+Q%Y=Qlm4-V#6encul9ypkI&7jYh}n6+nSa+}*s^UXe2_usnq z_15HXbN1%$zW%v%ea+te6}!I+{>os=SUjIwtz<&7w;>so$5F2e9=pqa4$3*}RZmpUd{=qkwUJHO`9)Zn;Sc9*qw(==r{ z-@Njy@jRovVP&x5=X0jV`k2DjrxaYXW&O)N<9C?F{lEhAe-nI?7cMG1DC<&I_9^Aj z)~y%K<~`nQ=&I!L(?UCLMkCQ%F9u zV53&^AqQ*z*!U;A!xk#pEOfiP!R&6PpW(jtA6w7vIdLqY%C0*1vhCvbixp83i*^*p zTn~BXb?&;?Uwrznp8a`zh2sb%M#0 z+`}GDm4-DP(O!G?^baV9-R{zubKsHmyC)hy*Oa;&U4I-Y=gmFmxMy4Ls$UB6$9d`> zZcU$HT=A-O#lb5pJ+{_;T6yoh@#C(iIzKo2G=5LI6sKd$#pmT8hI2jf!vqjORU zlzWUbKKv}Rse7{ikF5J{{v{cou53PFs(oei-PXek)7Sr4l$2t8-Ox24QjBvsa|)Mu zzqVGBewn+i?>d#AssEoc%q!k!Yx%O2M=<{W&C(?P4Db0&9FyFH%`ei zM5hat9N*>To%k{7=*iS05l5zN6gFRQdhd}_cJ8d|lYTQ6OyJ77Fs&)YP1*3@ZrN5t zkM=ns0opxE^OwwfkiK!P(ZOHLhvrQzSbjzC_`#-WPgR1CE;#bo+T+h;V|L%K&;7Zk z*&VZ)rnl2(n%n%(H%_YsZ##JC^%u7Lny;2vrZ!)G?)9!ZL-3P7o3*rFz0tY5w>92N zou6CAy(+%-^R5R6UtOH~^l8OGwyhR(7la<(YkSn7@4T97saD`as}$)IcO`79_Vn$$ zKOwr!er55u!2eONQ{)%h=%41DHE9=D@H?efSAyJl{*|8pbK703f9cs2xyOIE3*Pg$ zoMBjJerLD#ldy)LGM7_keEM6vnoa@0;ZI z?|88)``4WZWtVmLzy98S``wP$^M1=oJotD*`1~R@AthBKC4UDdVG-9RW2M5*exsk7 z9*JKn_5OSLF1xs4OT^{Yxs3LrZacUWWpvI&O1(Q2vF_1a&118h7~2AVb2W=yyX^E( z_~A6=^@YoS%uSdmBU^lmN#y93D;Y~KXf$L9_y_Mwt2J!=7~OR#Zp)m)6%&}Ubh~z2 zc(oQy<$Sr!C{C+R{(wO9)CcD(y05XZteAdn^7Q=?^SYFHTAZ~R`a_?Ucs%Q{v7f9x zM>P3!yDXpCe3$;$e`+~;TMVvV;a2IEm@L2&xkkew^HCOS@f<%>V7)td1l5L9^ zb8dqU^W5@-`g*%w&D!_V?NEX8|NRmFOQnA^u^dg{EQsVOR#}qrgo%Y~k${e!xVTqm zQ0TF8;cE|k4qghHvvCpYynv^xm?E3G*t{E*k96~VU6&R);jUP&Tx}^&l$@R6w`a`i zg%dtkG=Ajx!^$C25`4FDnXF)rs?sK_Mf+0O$!~4F1MBpf`XxGC9bEFRI6dp?`Fe9|(z+$T zHl0|n$=klKH7vPv&3AFp^;2fnL6j_?0}(e$^I2iMY7 ztLIOS(284Rxy-3E^=eV~wBvsQ6Xj?9$AC3O zUL~yFBI89@fXY3)ob@6_7c8;`O10k3T-!BSCdX@HRLPH+(`x3`JXPDP55z6I*t2)( z<>w~LHJ+c*YhGEi?dg=C)&8>X*N!Z64G$s z)Cy?kiE`-jI=!vVuP}#e#=SHV?<;Q7Mb|Zp&orfJ^|s8fSUAhGgsXn3fVSTrmd;5k z!CsR058EAAmNl7fT(B+Z{_>I}NtvB5S+b6Pj{BbWZ-s{O858Y&C*GbmKKW+nK8v3h z9eO95UiNw!)K|7y!rt(n;=76!RnPT0gB}(78w_aimLF-*J)3 z$tgkiggVQ0G`+5#i}$f^`m&^1>T0_F$XxWxMTCQ^zi;8 zN=FvVy;*W*s^Zsore_~NeZFeV$Ej_Tq}KX}Nnd7){_T6KBqHPZm$$t-`@Bon%Wqh9 z+o)Z3TbK6otLJjpt)Cs^dAa}cGT-B?7;oErSsu&geJfYv?Z+qn)jxw8YXv7K92c&? zv61_v|HOGOjL&VpvgDXI%g6Pm6Dt*+`i&O2`b}v$llXqVxaTsL4RcrS39PVn-4UYX zvubyq%Y!o|Ip358N?JZx$02 z{Ha9sGKKAFi5?5R;$4`UKL=V(=suM4YWn5bEyiXgQ?|)`JIBJk{jB59MWLaI&Ucg; z6D7Q}PE3Dfk-w|3RaQ((?m(5z@{0v;;)LqHbKEkMbnTcOa>O^QWxCa3AFI0$d0fN< z&2%QOZ|D*AS}=#js;0rja+7BGb-VbrvvQLrZwTY(Id=Wl!)wOBJ2oxv5zCFZ(A{v* zspSNxVD`*oUykcUmT4v5`gJ+M(_{Xnk}Y%jmR-!NP?1obx;=Et%D5ud6`#2FG1OIB zeBk}_mphTi{@XS`fpy>5CLUx|SDiVNW%>X3v#&}o98Q=d`QT^A>?usn%gjYROz+>m zTpYo4>dVSMMHl{S2FpZd8J`cb%9hC4IQit6_)R*mcWude{jO-;wyS&P);wQb?%Mw= z+5CLUyt@S^|K6M_Og-uJPyFXwam#uCS>HG;UYcRADy8V4jURX#_o>Y#In)Mgk|{=wQXj6ZYL&{cXuwHCDpTGC%4iy);U`)r&TwoU)SHJ zT07D9gyQ^$%GW!8$2c7NE53C3JCk|KCR^t&|NUj&LgTWub2GU&yx0F5T0c|&-KN-e zr>kxEvaLU&8h7dI^L2X5^A7L*zwOS4vb+DPKh)Lx->IKh$^Z9Foz(o<{@*YE=CNPg zZ*%I@(_r&AbEJ>|SYH0fd6~o3ApXOif{ZEqcCj?f-SOhsW^u7L(InTBEm!XtPMg^$ zEY~R{W9zr?Pw$62PtVsW+&K4f4*OX%(I59O=S5vM==9Co?9WoLY`)aj%|~?pY*T1+ zF-+{v_`Wkj$sn$~Vt+7GjR9YG$>U`^mU%y$BmXnHyJb$JVI)q-4XRNv(Eq?mR znLjx9?gW3oKz31%;PBMA!sL@3QSF6N=EW`_8avb+iyfvcYv?|-Xb+om)hCvoN1ON` z%!}*3_GoD=_cf=BonFiSTZghob_uzzT;vnDZbd+dW}0S5mshLMg!Azqg;OdsT|5Mh z^UnX7lOe0CrT?mBZN+x+oUOIKXP>M-b}Q7sY`v)Zr7C7?#=~#l{r{pKC+BV(v~JzD z?VIQQI>MolE1bKb--Sh`OE9qWKwQVS1?&gT-e>i@kYb&FDT?XYU6BbVuIxW6>f$HF zSh6iwbCc`kaDMdHzi6MPCt$>=zEb4d->jQ_ zZ)82XGuM?o&F--XVU~^h?KbId_rf3(lP7h-yC!vf37h*Ps*}UDtF-&%;y;C(y!Yvu z&6Vt%GO^Y6^1KksE6a3lZ{8^~XW^!Xsp{@d``Dig>~40x>r^w_>NSth9FYgJs*~;q zyf!*7mGMdcz@t3vrUB-~XHoFF0~;n%#v`@4>1`K{;|S}VNx1A^Wx ziSIpon{#oAQdff4nhA^ci!-lqJYUfgvNv8wkX82=%hY$%8)h_#?P;>Qr#Y*X?@gd<^7`2IWh%xO{v@7S-+ zw2b_#myxpHaOO4jl-H4*8iG3{KX6%A`N*U`TE}woXKT#gl`SQL%~2Sk>H1iHWO2xuwqWuVmSpsu5uD;Lf@jhkcF{gqpd|d#`bD>oG3+a8gxi zfdM10xK!)T*eeRB#Tk9R#=g?z`5jRxbg^%C)~4(G+*qb8S$M68JKRMhz{Md`sp`ay zvgLDDN-}@-c&eECXz~QJpxV=!3s1E9KQrNT2yP9!ydX!e@?XihEupinCb;S3I6ap% z-I3b)>+(Up+Tfax%eKB?QIlah!=#-V9dUV^lkLXyTOS>pJF_o%&${UJ_PPJw3cPsV z_#(h2c6Q(1du-CVn|@CCH|17*(M{3tr@PAT+U?)K^UX<_I!ouv*8G~+*7tuq zZdUHIdS=no6~;W#-CzP!k>%RMVk;VE#anD|t;h;KcyrfE$uz0w-zwB>I%m$X&sdQv zoMthr@B*ir%ap{4ECzi5auaC@~Kc<~`s<3#(?$r>j%^0YZT?&i3XB<;WT4%eeZ z{;6R-A3r>B?JwTACC6Q%u~Q^cYQhh%i=tY+;?d7b3(Tsl#dC_7&6wAH2sxU!-TG}Y z!h4i}=qjmN+bM=H7-JMXk3ghQ&2*nU5~{6w1yE z+x7ADl(@LFJcm`Di!b>r}t5bW_D!Q^IdHe7Rn&f2sb2*b~mmV;8o+E!kcD=YV6_ z&inZ{9wgSMOul?>(_PD@erZLrpSwI~Zi-sDw9WL&!4313O^W)`-aTS^_*77lP4ba# zCdcKP=k?m5#e1%7UAFh6b?zs@=$x-hJ+({cT(-0QoX~XH{*a2A>CK7HUW=>@$cpIc zD-|i<(aC0^mvDK4V#y=r1@`?6hIUFuj`QAZb3{j753B##g!Wo5TH69tv0nM{k= z^d^4BJT2cX3xjqy+?Q|sdi7|?i~qNyWn!-#o$=OisbY-Y5wYq!FMISGTZDaetZ&9BaMysEM*A}k*a_H4H zxtUoVubsBN+4=fR_=19qNwq#Xi)va~D<3dP2fcX6A|K(G^pwlI@Io8=nPR0qisv{a zi%!@~b^Yij?>*<^v%3BpFDz&8UG2T(x_g^U$%*gFRQ-P6t$*}VWpYwh=JN1oQdw_& z!gtNH4S!qtNn9hOK40ox^2<$6XRW+o|2*PePVpJj^ED68pUtm$eE!d=-2Jt6DJ5^q z6qK!%Yuc4}yV%{E^M3i^Fzee*|F^AwIe%ha)veue+cr+xbZ-Ki*vco%Bw3aeqQG7aK(|! zHa{v`Sb6c^hXNeJrX{YgbAL5Se8?(YxF*jwuTWP}SS(v}{f)lu+df~8)r)x3)2%Xb zKG%QeLPeVj#q*#4?V0hiv*D?9T;|lP*JiMkHf&< z7JBi88yikfjX6>DvZwKkqbII58Ff|u*{pf=*0Z$zPrD_iv8x2s9I=VfX?wEx z^#643?@1vl&*ubA)Zz6E3Jn!yU=S2sxhJgs<)#^Jr@l_UDReEe?I|N?OKQ+06;B3+ zhDXlNUOswzTOyg`sCM0wY_aX)iNNd)aGiN*|g@lH(GB~)n zO!Ch6Ne(eV5qmcEMqz!XWt+&Xg`CMfhHV`k9-2$1FflL)IB{J3QS`nvYgwbv%j3)@ zzOOa~ckby4uB@J;si~ywb<=uh-%t5_kuxfJ&Ik)BwIof?IQ{g*;wX0p1)1`$XBUh% zZFuvSleM{XlK${@Ea7i*GAW9h#i_s_z51a0 zvaLTRZQk#s-o!Lv!}CK01vU(3wzhRz$BxW79J+1w;{{h2pJglL4|v?jc!ZnfIf9GFLvU7_hkhf|&3n!ZehUBaOcXiRA$T+8*FS4-p0JjZ zpZm55^L=2l>Hmdi(PWtvt+7*k;l7U)#FLYwaF8I z6g^g`m2+ae@yztd+f2jvH+NhUR$UhHCVlOzMO#kjonBjTx{`g1bw+nyd-k7wcG)Y% zTI2-{S1NEFbgFybdGz11$+Lc4p8sU~9Jz?KQ*W&)nryB4srT8z?(GZfIREdv+;+I7 z+l47)o?qn4tKEywg=NS1^r_lSTzPZ(tv}`~{}o)<-2d`|e6Mwt?UR#U?*j`}PyfGD zY~|_cvne%#Nrp}J$Y*tBafN^x1%gh&hd-EaGn3W#>u+(_KQCi)j!Q%&nbmcp4U+^7YNs5Jo7SP|X_HY^w{3QX?&a9)NAvWXOb)+eNK#j@Wh_*Cv5A>^ ze`^+_#&QNNk=s1449__jPOwle&6?+BSlO2lu~f|D*q*716KlQ&Y5ibr7L;Jpm%r>i zWAd6SA^{Et^Nb!wtXuBG7$Q1HwBS(T&S!yn2mP5e4>!afKai-UP$F8EYv-d;Gj| zQ#U&&Je+uI&BV-Sx~-RXc7Hc@C{OQ7o$0p7MZNLaq3vcDlD6K;lqgPjRc`hA?ce)t z^8cz@K^`B|7u?oSJ;~>nHZJq+)SI9G#JAtz^rV9~uStlAAL_D|%kDYgcd%@A_qp~n zrk-E*H6Dn}PE8AKwfwL=dBK?^p;)gEb_c#Nc$!CCwLBn|cur)VsLuMptng`uUm9My zCC*yt^>Omc$4Zyl%sG#%ZnZ4v3=4PO598Su(CL^ z@TF4sxw{|s+zVKou3U6(>Oc0~GZO?Yjboe5?<$({MJaB*Qd`?M<#mO>r{amj>WXEG z*XM>kxpN>}BkbERJMN#h{{C{dA(LLMm>yB(o_pH;+;>Gz8xC$=%?(L+9GiDXiEHgQ zNcKEAN%^~%)6Yv^zr(}0QTLs^XBU3WeR=S6WbF}-1?_j8 z9&2g%n4kO?acR-&n8HL2Z~e}Pr+EK;vfQ}N#4e>VVS;s#iI$*?!W!w08+sn0Gaaqv zUb4QljhXa(=Se4z`L(PPag3_5VlF*3EtV_)7qG~Rgr-!e{VUmj+3`u%k?S{~*D^<> zubJ`jwBntkL9AjTe0RO>BvQjBP)ec7%j!OnGXf_uOK;Lee#-n`^xV{jPc1yZ^DM zaQMdhd9nMSr9Y&_g-^4b?^f+L)lbQI@)19i z4L25j^L?@K#>~~uvE>`ArwdJbeqqV#Cxz9|?>&!E33y}FS$)j=&vxOD-A~faO?$lW z7pwcOdrvJV@s@t8c>GT8l8AUu|NPa*-0#`(ES%#c?sDR+ahVG1k1Z;;->qU_^0pWE z>U??VSG!U9L)W}#;X6FGTYUEWQUCV#YtcuhzR|ymPaa-kdbZ8N)LK;BS!L<{5Pr6> zk~_DzPHsN3H=)IuM_H&Fy=F$0R$?Y?P~KexB(ekdbC>S8Mz;)#CB3XYZ~mM@-MmF@7@P z#JY&NMw&N0BR1HVzg3&88LLt@@9Y}p>nqFB*XG~Y-T(Q-{?`>+t;crv{Fd4&m$s)# zn&H-|*+S;eg3VS`MoU~>S1&NE%8`XEJECID*NPfX$o;`lJGMS&+(^UD|d#Cx-o3V!lr@f55cOX56#bdHR zQ}&6~5pVcb$o$Ehb<*r-+lsTg`8&-_SL_unJp3%T?1kZ4Ss#T>-ARmRE1!LPaa-W5 zdr{kW;T}zezTaiX?(^QOWcxq)Wc2E(5AS9*iJzNXBlV~yx~u#7Y*9Cx#4`sKGg@_2 zPaQk9^}hHD_9C@CPZ-kKVkNF!e17fhTNdW{2DRT?`en1!BM-df^mABqZ;HD24Z#jJ zd2>I-C^6$hMlTEh>?{#*tTknIe$Wm8-c(F@%(GAslDG622HIqc9ne8;JKO($L@Sv>m z))NOsT52Y^HWf))bg-V1Q?KkiEGoTSt~b|N`(Uj{fAh+7&4+n2@=A}}SSiGO;3@f& z$QZe4%c-){Yu|-jaBQ)xmokgDSU2PFJ%(NRrj-jG$ju9@l#Mf%yXQS|j%LQM8w>xn zFLk-|dg1jCUni9aZ(H&4sE{Y$Vi*2Px2IG{?EWVtq@Xu@%2zwv2w~Ma6|H{Z+h=N5 zoXX&GFOa#vRHrw6d%j`q9PYCW0-y74J}J?W=dN3$$-lweY<7rcm(iCg5*oZ(t8(Vb zl&T8E?B$%e=(`7s$M4-cS0p&JBHHP-{|m z`RtcF=Dww^@%^!X{v-x|O)dLkW4q6NVnJSQ;$&GO8Q>)KZ#iRrH= zx#wgac6YCuv}Fs!Lf*?+H_r84Tq3@IUv$vF<4NLLi|UHM9^3o=scL}9y0`^3|EB!s zTW0lOqsh|Jx5qQZw{PEEyPscY(?9FA6OR7hS~v5V-L@!}HH=F?6?pNg?t1-TA~%D_ zyT?s^7QCC-^!LRnZj8~lIi>#BMs+>YBNirSucj?Mwogv%Da?41HEG(lttyu%M%!A2 zhQ0{ApIfrD@6-MCn0s%eR^=`JUtN1ry!O_+;HvE>8y39LjK3Y*sP!oKn^3y0_g<}p z{_e6@PhE25%$_b@ligIxAtNllJzwix?(HM%Yi@54D{~8Y>+G|p=fnGRs-G5A@V;q% z9e0hZ^DE2K7l98S96K>}sh3?vO4Y=Zne^5dika5V-{EV z4bKZ6s~;?nNCxE#yOa^f+hrPCsI=I6Y+>_b()po@Jqn6Aq6*{v| zm51wJH{bu|V$MkyJ6Y+Q#Sz9?59F3Ued@P##ZBckhIbanF)s|eu`en4^@rBqiEE;Q zCfKWAe!nO3;sRt>S8bs)&S{x|Q1 zmy3&E{+yIKojd%R%iOu~u0ORy8_FDo%(E0y_RVDMsZjZ$q~`kKuDT1`f)~C2E=L^* z5emp*Uubj0NO8r6jh*5?VNPpyiE|}Bc8+UE$uMD5i?hgTU}r48v-*DiUH&&^@6WEu zNZlbl=U=Monoj-SraM0bPSxm5+j(K`#$Pt8S>jWt zPWhnwZtd#8tm^OcS?=7hVUGQBylCs<)4P-8&ca<)k9i+ty&;{WBj;yy@3IETBZmrh=FjQ?JyT76vhvN!Vbt9Q6{on3i!#T9d> zkR^*2ElCP;x;V*vqKR7esv}&6k-VGEwsCJWUVCN!|NE|+ciP`yGVk7>n&+-p^?oHx zUh=@vvn#4x-}|#;W?;$E?3i6~8?v5Po@A=&J8FV~h=!z;i%0q@iiP`Ln_x2QiO5<{&IoJX_}#uG6GK+%?K=Cn zQuJ2*Q>J;FyOlXCm%caQt$kCzcAHR)-{srq7<1lxbogG7XD^m1L1 z*!&+8rt(kzylP|P#g}2{c$I2d#2OAW6bSUSc3uA{C#SNfd|8Ng{c;gIu_HVlYaHZf zEE76($6~kBafdZm9J?i-$gEq&&;6CPeCcZGOeyQ$bh*X*gJoD%&eZrN)^9o0r>W9# z(K%434bS$5T^7dp5*Y>HQL# zc;Tp=aP*T4_PpsEmqdT^_LK9GX^WTNsh|E`V5PF(EQ|c|?X&+BZk_$Aq^|iwnEf>Y z(`Rz=vv&GiS+iO>Z|1~Up+L7gkFMo>=Cf0M(x}zzT5q=1>Rj>WtFMn%KVaEVEMN7G z%j51RskAxw++Hkxu-2PhJ!t#l7mcCIlAf3*U7L2kW8KHo9f#e1cBitIcI=Ia+3?!r z(4EWDi;}Zm^(`~Gn4Y+4>D!Hqr^>s&kPElbQD}c2bpCy9_V4DOdvBgzw94}Cmwo)x zjMci#w^bWe8+*?_-hG16$L-ZR-s5tgJKcMKrUw7tID4AuvIE=)6Vk8BM=g5CclXXO z{!goG<@IMRK3={i+csP4zyIzv^`1NDlxXU#nOF4kCBONv=`2@_r2OP`_nBUevA2%k zo6V-UUMOb9%kMs-95N#8L0cAHb!?vRBmdc{ydog4H7(YbeYwVR4^|mAlK{&(+kNjo zGPm5>S9E6E#g(5vNDD?fORZa`b!u7vy)JQ%Jk=fMtA7jWy|%c%_mm*V6iYoD*<_bu zf&A9c?AF+6nIX9+`s^6vj=J!2>8|?8{x;Mxc~R2dt?yoCdhkfFvV|p_yX1Cdd9T|K ziyeYr>zF?+)G9sn-k@L(_f_Gn%P-n3-7oc?Q#8D{UZY=E*7Nk_k{?YzoPrOZHS>Ed zzw74W@KbZM+gj5@fjp`nv&%Nm`f#N3%jx9lueQ#b#q;a%N1>%IQ#G7c_{#8Ybrp~oBu33_3mW@`|-R}HS_mA>7Vqa&B`LY;N{ER7u}84OU-=$*Hn4k)~`Ha z;nUS*ug2u}TAEe7FT8p8^OJJbpMug|Ke-hSuVa94ht-@2Do zy}Jdux}J5vvz?K2@@RZUZ+MKj!JF^vb`?%cdb!f$&{mIc8ZNT7Ql_FB4D%mHv_#zD znadh@L)7|vdSSuejmEOK-J~XM*}`GCyRYEnM(!O!pJe8K;a&Qz{NJ(7@1L@E-28j5 z!>`FEA)P8 z4vlgRE#ho%y#~K_2LZsl?6gu(#|Zo`t#rUS(oZfJYTAo`z`NGTNISaW48IVWuQ{{sh`b> zU)zjSZmrEVU0Oda?&bfWa+%Wf^V~}oZI#d5&6F}b z{q3HkqJC2jD`{U@5fu9^ScijeRRjB)(i!vexo+>zxVHY4n9f)8Cq4y{{4LKd`qPX) zG4?O!`@ir0+LaNefo;ECUjC`LY@7GRA%FJQD=ynxEBS9&8Q$#JzxY{P+VaGJke{-L zWv7PkD$(0gZ@6R2w(r$Pzb@-v!S>)(uJfx3<#!)#-R_3#s^xFBj^MGYm})%tY~`H! zr)SMSai(+X+(*wVx4V1}mh*qkXmy{x*C>RIG3<>gZ^GikHU%2HSFzbEy_Zd`XDwdt zck`e2|AT5**8k56xO0DXyv|>)>g*ApsM4%* zW>)cAJJWaz@24@%AvHf5bJUnt&hUN5p={=0bFXsiI@^}Hwhs^Eo`0V7 zwf#NukNb*eoH#Ut^S03Eb#{Hf8^aIGuRptTRrSy7i@6>*KdXrky{n zrk;;f`Pz5EboTW0j~sa`pXxr3Uh*WFr{ig3NYC0EY7@l+mOpyzuR41+E8C2NW{R&q z<%IQUgs+#P+XhAGmH44d4dUOtP> z&D(#**m|4Nnc7EhEPNjLf5^```+LGBo{ta1&g|`k zV{6%UeXI^K?dvmTTxs+0Tj3#7m-MY&Tu<{@!@gf?4PN(DSY~3suW0z2{7JJ#O7H)f z@X`J$BTu5ce!=Aa7i%MZb$b&2Fuu`$b}OW7gN*n2&d{q`Po@V4yeVDd`*c;P(){~- z4fgURf828PYVfx!CH1HI_2e$Ut=^xxvvH%Qpq<9uT8*UI9lJidyq;*Ys%(WHo94So z#?iL^kMo)`E+nsgV)M?|^R-0lO5b}*#(l^2UTv5c!*TGr+a-xsHMi5xH%O)C-;c5M z+Vxk_bxqqgu|InY*9q)=TO+6vZ=+&dzD2geFmvaY@bEUv>vLZGUbIxV!sXd%?}^J= zeF|9Sd2eO?;KI+TXR8t-x9PUAsCI+x@n)Mhk3yNX6Sf7cI=n^xO#H72x%LJVa<_ju zeQ?h=zR5P%i{57GUbm~Vn(<`2ewy9ln0;bfm0s;lSyJq(cfZOt>&k6q(|fmSV^Xf) z*j2oJP3Y^EljrXC8r9YwiiplztUo!ka-HAxRr{r7Vp^QlwZ+y5_P8uscd4}Sg3r6` z;8*vAPfRVU$hkS=#nlBNTvGy0@40lCk@F3U?N!Ca^QIe4on|)mp#SWW6}M8POzPZt z8IMojs#3q{zR>A$Ouw0WY_eW_c3b|H z_epD>x68)8SN!)~{{CjZ^_LGDX1%k_RWmuk7If{$mn*&L%_S-Ydgof-$YreheRV@a z(LNiVy>p~z&)(4!=e*&2Rnn`hGhyc#CO%)n_%o^CUD&f(Pweh+F|5ql*HU!ST6zli zXYS=!nP#v=Y8+Bq8ZFP)+x{_V0>iWirEYsf7$TaYHh*d9h>NwItWl5sLS0H znYr(}mma@)TS~nBYwzOhjuIWU*|XT9rj&Nnlui(y!RR#kP2%66-YBPozazG$ecLM? zXc84|q5Nz~_Ku}rC5yzAavx4QvCDkJziS_sNUX^|vRBR{UGY=m@5GG})**WLwxyQ* z=B#C%wzc`|nz_BHws(Sc6<6s@Jui_gxtC|(K86ONNv(_#TxE+tNV77gZVssYCb{F= zK}NR?xe8yMb6)?HY?WNMLCf;n?@WevDXls$Yq9*oE;)gUSyOZJ_f1`X=k@b<51*$0 z%8fnrZbjpYd;2sp0=B$48xwQp&h1M{7aoSKQk{RJW_Rv#pKY7h`YsUJc9dUPCUs%y z-gSA4_oSFEet41Z;`6y%UjLNo>hYKosL@h$LsKTxZq~QT_7#6Fo|jZDx_itm_Ho;q z>6V*ne?Pfdz`HK<%Dvi~o2GaBPJU=7IiZmCPWGSQNu}PB`H`&imj2aS%d8z8x2C1G z>H7TRVxjXjR#zz=Z=cJmeO>f#_x8MR6**@=#Qtva zD%ztx_nE4j<;~x_T5c?z@%!hS%lUt<#m)-9bLVzo%RqU4li-j#PmGS!-=YhAvTY2*-fVD0tX%^BB(UMHue?OHms-@Z?` zc8|i1Ti3oF$=$}DY2xda=ppib*L<6oi64wkrZh4rwH}+dc+T|WQLn!9@;};;oU5;S zyKY{E9OJpJ$aAOPYxMaPZdZ)9sJwL2v7Y;W&`I^hLUZ&U`Tv~yd_(P5)0!BYr!8IG z+wZw|>(!ps`V+Y;=$^9W`J7~fzcwpV-X|WHz9nzUqi!hoI(X(;-Yf2PUnUD|+H_oO zviGZdPtyPFIr({Wv#H{h_wPUFw+EeZHge{W`L}ZM%JZv#E;Eejs1cUyl+m~OSZ8O? z{YNn4=hBO}CH+l$H+B87n|DlL*@NfS|L4vZ@jYtr^ozpxLx)Z+iTJwR!;Alo{hp`| z0#l161=ojguTU}E8QQ6+c}N4}5emtD$8P1*XO(#2-~N5>Y0|9KLdrybGw zZ&zr&iAOgx<lZa{Ub97#d#+a4V(Hk4ZF66~H;nl8M<=CK?_Y6<^w0ALkGf`g zyj^?iv|rxAEsxR;s!Zp8yNv07K;zE*?9BM;Mdk+Y*`7_ETey)i(I@;rPfbb+n`XhT zZ+F)|d>FpHYh&3=%k;-i;=d;xGc%aPxy(6Oi_?FP>%^2z3df|QWu^(m3Hpi6{rD}x z-Nh}()Zb|>*kfQ^sQu|qSs|n4EZu$LEc$ZOvhBt7+tuqX%*gK! zU9$e!0zrhmyFCHE|-0oWRQBE#K>BuHUpE$`+E^FH-?RqyoTe|oCf#b}_a*~Rr z?`CZCt7sPpw33|tAWK+!_70isbX}oePuUjySDW$nmvs2PtO-YDZ*N%jJNTz8eJ(HGIG)Zjm>_aw-JDXMp4zVE*{>iH)xIM6=T@X`4x59J*{yw%xs#^2&{sb0}%?YUuwkDl#6voQC$!rVE!cb2gf z9$N4J@axv^6OG#CF$P2hZbbB95FWG_>5ax?R+t#=<+eoJI8c&5mn^XBj2*x%bc zcd>ukqs&(^d%j&le!Z5A$gi7v-+xJn@3^&0G2%z=hew@T%KnBwe#j&0mveaTwR7GN zp4n#3y1eH2XW2b_S3b*8(Yd~gTV>@oE9GW2)}_6tXM{6a%_)nxufj0pduF+5_Wk|U z|NjL%e3T^5`0H24DfjBlA8hU#Pn%eIQhl`3BH#}6-aGq`wxeb&!sA;+4h=2;XnPIOA#m7yBH_0xJI0gJOi z{*71WiB-k$?%@~F`K!C{{?2A`UU}(hKin_c9;lr#FR=cx;V;p3>&~92D0}>{b$5XK z)#me(EN#^vrt&i7-rpUXa3Nk?_Umu;JBdxD%+Dj`pZk59_q?)((c`ed1Z7*ReP>;| z%%xYkt7amU;&94Icrd+vmyL837%dZSnlxmKZtTcJGC&>McjEV9@ z@6)r*%;HN0R@WYR6_ixE+?P8@Ju}Mv){~|4yw)2Oh1#51uBAQo%A57CJbpGV&di^^ zsUaq8PG0L@*0V0RoLa5Uuta<9{AB8t{5E4@ja)|W^oi5{A5C1F@u0R=ar&~8wTd6C z^)uA|2zGM?64Jyisp!Zn?|sNu_Qfc5Zjki{>jkBED)M)gKRjHY z{jPqmv)>n^+d);6Ci}ftSW_-o^X6{TfBDJopA_p2Pu%DED539P@yd>S@`MBTTfAn} zXg&Ni*UdzAy7v{Owhy1Cq~BB4hj&@zDdi{W}ELiKQH&~=l}cfzffH= z>rPET;-T;gPv#|t`SYx2Pm~X9dd;x2@|LC0ftTtBxF)#ER26MYR9n1lwcNtO{zt8^ zUTZM3Brjffmb|K(cU$2SjuIs8|sN#}L*g6FJz zQofqX|Lw?9eAe0ObT{xzo7KHoUFouAk*ZybllB@fzvf+aNZt}wTXk6IP|fT_Z|Nobf(SzCPH@*8${x4s@ zwv-`DMBu`!i>n_mc+Xxx@&5k!-)t?Gie++g_jrw#^M70XpUX`3J>Q3n&t=oUcdZN0 z*3{qH+hCfl^1*D~MUIfd-d^AJH)nUrtc`sXlX!pka*Nz=k3XM#YCW&+@x!~PHY85Y zzGXkZN98$}EZl zj;tdLdy3M2DZJTxt}Zh!apt#>&1wHH@@Ks-{Ni7dykxR8N4`?6nRivT-zFO9?|v!7y2T-dH{h?LlME~9Gf=Mcum}6d?vxt!X_u>w}mun{~ZB0AH!p5F$D6>TT zv+T!qG4YchWt)9GeV7+s4*9ru_Kpt|Iz(Hv@(d+9ymZU=zLk>vBsxp!M&>~oW^b9< zL3_{f+?0%1`N;71(KgS*XW+`z->exJ7yw24GfDse literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/Inter-SemiBold.woff2 b/frontend/public/fonts/Inter-SemiBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bc76d107f6f5160ab655aa1de9fd2e7da31ef3e7 GIT binary patch literal 17660 zcmXT-cQayOWME)maQVX^0HSwnU|?{W1rY=BQL$8+^kWv8V{B{*_Q4^JAubH9EP@m{gd%QdksNTLRb>c-W=umayO0Hl_N;TZV(nLzijK$oo8Hx9FVJ zY7Z7qC@TtLKlSgwzlYy3feVK}y;^OxhR6G`l4HxsoNXG~EdH<|-9t?0wcH zbWzx%vcn$xuTnV0);X2s9U`X9@-9}l0` zn5$erY4Y^vj=LZHzoRnw%sZPyqIO5#?F{<6Hc0jb*RylyKNtTlzF&P?F8RO;Ip)M8 zZu!bBhL@afaW*aH(MjmI;UOu{92nviBB7`jabmhA+c`JaFTWGRK8ptaWYI8^G@g-r z_p=Q@S0YPOg2;z`?Wd2t4VZw(KR-|jo>#k)SO)ao**SNnYR zLxw;FyI_(wY1HEPln(3ZfF{fjqta{R%zvX!;507{r?wq(Z z)1vTF!8MaJ7U}n9mR_4#c&##hZiKp<=$&ULE*-F4AW_H6aOXPN&*$$yv%A$R z>R`aqjUrJK8or!gc<=$!F^xmqGu#$3p0w_MmC}(Qbo4}#Sa$!m|IzYSX6kdyJuqR5 z{Pn8>hYkyQRI&cvWV!d_`ZK~8+%l}?AF!=46!a-QR(UA+*_F^aXDnJbXxJQDl6j3` zkF9)r*w?_CYU?Kg9D&Q9I6DLgG<;WFUgthO9< zVG$Kx9I;wr#>N)~>tdfAUv&5X%`INPS1-@0ypXFudHP;qmD4gu-1XP5%PRl=KB#-| z?YCxpUW*kYnJ;Zf*=u?DjL@dHtfx;`9QaYx^1-6R;Esvy0fjv#yEOKw?CIn`{9)O( zsM^~%6+3gCg`7K+grpSzzSt)H}-jjS|TP4Al$)8O{;{zJU`YUQTEgbDnmh@ku1_%?#y~)oUl@?{jQRl$c;B!JKkQ z>!Qo7h9tRSjo?Qdt>SW1ZX9tG*pS-7y8Gd>nT83U{ydrUG=a(Xz2wc)jhq*kunR0u zNGwred#&ZrXUoRX;Q93U$$(F{t~9=`Yp}j-&Y{8g>gmf9yW*SnNAFr?xz6^rY|FJ} z2fn(w9qsP7f581pW_^fh*RQ=Z{@G-(Zs8O1h`C$V@2`GHZr_vNVY$s}8;_e+OkE?j z=OeoV?+@9w1jE~{%o<0s)?RU_cpPnX<<_kqdp9bVPbr;!(AU0kp`(=_bJn}X*~}dd zi>G}4y+fA$K=_o+lCyJ?-^(-V*}t>uSg$W^t5w&sPFe124`10Lm0c`brd2Ht-*7L? z;X7B>T$`0nSI@i1G@SOCYB$$y<}&Rtku4>0t7k+N#nkPe7q?{1mhHN0V$UBwbs+48 zqs0324%3txWYl80&J~I~4soUl+eA zT;(Km{EGV1^lp7k?$uL$uR3hDu~FbMzP?>#(G?56ZHIm`M{e*|%bER~bKf=vrR5>~ z!QujYyE?_VC*|ymWtZwY9po8sNb_uR^}>dlwmPvup~qp5Yu2vOzR9)!@t(Injjobm zb3NuJd|Nl^y>iN`e0}%sqE&rcMZ{Xl3phU?UAyUQ%w^4`=~>FUKhNlxoSnnAc*g%1 ztbHq7KAzJzS^V|y8ph)IEss5!*D89QH0qtGx1VbYuic>sn`AnVH_cMf+OhDgc9#7}Y*eI@MplG%^DfPgmuTR)cZE}8gWB>yJp-8JsIkyUe=&_ z&MUMwu~#j>up(>ifwy@FP8_J3CAK%R*59_p-T1=U>E(seyO`X%Z)n9x-e+mDm$7u~ ztY&(4?#JDa-Zg?n9=8IbV}-(JYg~I`&-*~A@N2HEZ*0@26#cnbe#iX0+?#1oMV@!5gDi#V@n( z>=rodASa?DxxPcAJ>kRtx4*(2miIYMw_|qenQ4@Ku1Qr)JLg)CPvYr5TlN^6b1^%L z9-nJgw_{wWaQUo(_l}!NE?q*pZfh)xRcEM&&SDiiwc!p|jo|Y(<_=F=r^i*k?cIK_@_lo^ z-On73eTU_L?B9^K!s)W2>a=Of;gwTE1B12KZoL@%-&-wxr%i&eUbTen5!rLvOo53z z^qdqIDRJ1IkWCP9i#R%ES?cL?%RWl%*ZOwJ>irj4tCef~<)-$V#oPZeby5*Zn0IvI z-8uP86PG`oA{PB(S$M&MJPj=~1J39(6W!Lm-19=_WX10Ay|2D(iYuOR#CYxYHNtKC zwYQwOQ~LCbG}~}=o+*t2LxQ4 z;L}{aYDarZwMq1rsfBUD?w#E)gU-HQv}1ab*vbMyCD*&%yO)HDy9!J$mlQc2_j!fY zgzj0f{6-p1p?%H^U$-;%IXJpH=b78eukSFI-?!}3WB(~f7CJaKwQ`82&62D?C$~dh zsdFOBXHQjc-Ip6-k zwNKWoYQ%clZ9$%L6A3jA{VU=#@tc_J2CFT36(V)B8Zs-BT6J!wT)d-~yQ=0*VPB!# z)9}69(;wN^e%Sb{Mj+QUDv9`+^0F6r0QP zqRunVTD(^3R;Zb{n#r;gXQeiWo_#5F&DHSY(cYbb+w&K$oz}idWA>WK4>P{q-)6aV z?)IB88q+7O^wvCgqD0?vZ-JbE+3LOXPoH*=ZL`=mM=V)frztC})^qdZO{MdX&%L5Q zi7Q2P<@-Gcf>ocrowjYc(c+BKb29`YR~N8wX-OIREI;B~$bWwC2WO^zx}QQehqwg_ z9*s?o)nFI4U}6>J{i$#~rR!r1oY=85Xvxy2nvt7MCe>E$e)#LD|LT>Y z4H_axy%z11%@uk+Gmb(3@!5B=HPh<6zR2>4*7>ewKX{e#Xy42iyn*btVK>&?oN;KH zEML2^%Ez|1+P~J>&hgP!tmV(NcRu8?<6-!UAF+A=gO8d?ZL$jdZ}0f<>k`KvE%!u* z6#uQ$_nx+Uy)vWw?;Cmc1C3Wdawp{k35b=~%*;;NIj>38KanYS&k4@=EJxJ0@f?_~ za`?k!0iMH$H#QkG?0u4OV(YA&uR^O0%I9*0nyotNwXxZ&)A>|d@7C=ZWmm$~U0yo1 z-CkUL(eKNvO?!8hS}#!7ociu0``yF87H3pUoc$mpf4=PJv-Jhn3c~ax0?NYh&wT<5t;yzxTz2VG-={Js4u{mCw%9cHuMZxs84Pvx1sqQNDBrSJ-uXnoWcZ#W=CF&qQsb@ef8*m*Q83uX8vk3iKxe0`8N(B1 zo8S+)CX*yfWBj+z?!G2#%I^4QS@cF@A?FSw% zOufG2x$MC+GS}zq6x-#`wDMHTOY>z%)cSvMzrjgdU^W#2)6C>XU?6& zwfB7d#9bn7)7HmK<5ixyKStuTq>%ty(s@_Aa*1OCjY)?BE-)K!U{wFW5YN#x^S;OD zNz;~|Jh@9`sY!K_g|6k?zDMCs1-lP@mkggI_qS=2jkCi3=5_nc+FI2bjtO~RcxR{6 zVEa%b*{{&ZF#udL;1uC3cFR-d7=jqLNjF-IGPCQpkR_;r@S_2EDAGvsfCN#8wE zx5+-Q?)A6(cMBOFJwEXK#zxB*ihQMZ^Yqofz34BPb^gO#)7-Oni&L+^el&g8Ea~dc z`FCaKv2IR2*LVJo>{S<^Yk%hHb-S+KbG~d}N~>vKL0@)G+BAu0EOJgv4)1QdGfcSL zqNH`mhfmli%O&Q1;zl=Y2T~ZO4=rz40-rsGly}@ykBZf8UpgXi9QQm9eJG+JC+rPUpW=;A7WTXdGsB4AomD$lLS{O3;XQeAv!n`UZd+`8H| zk#=>Q-g4}^bzF`LlboXZ|FBLd;`ctI6zerj_Vl}@Nh|XLQ#5U#Z`J$yVjc(kmP2jl zcE{Y{pShurLB!GPrxJhkGuJIo7*8CVe#`cdIO~=bEPNZMJlkelGySnsjkJch+?KZo zBejGdngmT3>3Fx$*=71h2CmeS$gah&BpaL*l$r*5+K6w-&M%pQ`WFSgNsV)3#F{at58s=FXfDnJW=gX{+iqybB!*kLrQBhOe#6Lj$n#C$#s`?z%} zNj643-XgPOsk5-VyLOY$QKcUplNSrN&gxRV>b+y8SK?iucv& zp=cL+$m{me=L(BBE+;2*JyTuBr!!%e)a*+q{v6S@WInLCRn6G>PIU4$&xspEylaje zHtcYk`oV=E?$OH^E~?>|dL`WKA6mz|B+s2Blk|dF!6S($E%I{PEk38H2Wp~%yA2a^ zXDUX9cuILGuTtxiEM|D=8LRo=snV>)2Uz3s#O|4R@hx@aw+>^llQhstwy|-Uk>>V| zRqUafQRD_YL(^`jPb|-uFjV%4HpI&v`Ba(5kr2|{z{_559!dyTdaX-| zR_o=seo06x=5*PmIa~}Zlh0k=YEWKj@h;5Z#;ZqSn^sJ&Y|K0t>HNoVTjxp3DTi;V zq@7FD+t6fPa_^E*d`ik6fzpgKy0$mHCjMG}jZ=MY5$lgrfg+oZ&9eCKSjuPl#zVmW z_^n$1U-Ru|fBJL3@&4UEZx0ql>b_3hoHPCIrg*;7g_fMgYGu=Em6Wy`&GzHiTwdz4 zc}v~lV}I`FRvz`Lt9}~yF7ME^@T+OjIa|-hd*yCF?6*5+Z`ofqA+=&l<@G@~Sbi^I z7M*mNVaE1-PkcVM9b59g)Gkr}zWHN=*RjtNpCuer%yF2wxS_38eV)!j9)oF}Ut$#) z!fGYi8}IJh!hYd-lS=QZi0YWGv{Y7yMKgVTM8758`L@KQmGk=vh0FEcMk*|dqPAgE zk`J_|RY~w_%$%IsxQW+^+QhomWs|KGAO zke}xxdR|CBQ8xOA#eKXz4{-L7hn{T?-Y*VXmct9JjZ4GT5>d#OXsZ1vH{s{-gSFL})edp=gx`*a|fByY#Z(IA6Me5evAF&k~wMYBQ#lJ5< zlzvGz@V{dB&-V=SslW1La;i4R{?j%rEI4p9Lg`4gQB8YBc!!h`|L4iy)4txE&~aK} z`-!!WA}mkYZ8e@8wZ%&9%RT1vf40c4Q^?w$a3Z=;;L)j=Xod4}PQ6}p&d)41Qk9vs zpZh@N-&B!$nc_pd`h0~Cx7#h>;VPuZbMAD~gUX^Er?z(S^EA7yENq$hRL$z)7M1WB zGZ&q22&t-Exk)FnXqwXX8UOUSX5{6}^n9xNwjlY-lRdkRB{shM_MD|L<(R=XBQUEe|lq8hw#tBv?~kM zlLQ!&N;vb_b0%lJ@YwITN$S!$52cBD2?u`o2u zoVjt!kIU@`&nY&V9c{|D3Qn7sD#5HGaBPi==OhLO2ARUjj>ied_vO2~FbGQeMXtDe zVpjCEz!Tf5`<%)a91J|M%*0cOfgvqz+S>12GObL{=M?v;c!IPV7;k`zr}y1o9z{kP9WesTYO^<6TMeb=4& z8SnUNzFhX-|FyoiB{gl)ve%KF52{L5 z8n_N7aYX!QY+wp`z+k~5)1Wc)Kxzbs+MLRp2}bKW_2xdUstlVa-@MUmO=~H`>6X-k zNt<&b-|6Is323)p%{sbGYqM7RYoC_R0v_>uuO7`>Q53lC-9H`WX*cV3Z`^Z4_iM9q z{erh*ejaLz;wtt$*!y4=AH$y)ysLsjmuAh*n*M4}*53zz53X)bXS>b5Yr{@~{09@+ z9O9;Db^o0`{l~uZtYy~A0cs{l|#QDkv!;DKy-RB=n zpVxAA`J9~N<#n&-7gX(V`fY0cfHhR6f>S|ECF0?k6}8tgCl`Ah@op@s6ihqTzgA|d zRA{A)|I@JO)d!_~_Z_(?{r;}eixt-kKb}**&!bo^Y4^Hw_jvbTLY=kDbY39;be z^_(lFmTT1`VVpQ4VvT=x?nS9+{n_d28!j;gSAKqYe!BcbhlPiHgg?#HE#G!$ijUEJ zk-h2_8W)AUbvH}w(As;}a+2|J$=qA9EelF7-D|L63%JKw#BPzmX7Mhm;U(7vJ%$+x z9i@mj(7Up)bbs=ioRJE3pr|TGb_zw-Jo$R>hVjV*mJ*sTCP=-bu-@4u*JZ3 z$+0`V6>MjgrLe`XFZ6%iRJ}#j(e}ZfPN}Q~ej4i(&0j6){1knOm3v8aDM#YgcWh5h z=3Tt^*~G$2_w%KTiQ4{rTlDzUU+?j7J>7Rgq+cO<;qvnrjOvtFlp&q3GWYN(ub&F_+3q$Zj$y0X0sZ}1r0ohlVUlPbPkz3T~ zZWlAvEUlZ_;qLs!^Ou)f*(TWcnw7|u@g#6;r3Sb~Y6^ zrL9<|t=jVXki_4|CmvgIseLiaiqz^d)66(N&D?i^Dt8RW-^K)f87(Wnm^ljP)h(wK zaAmG8<2Y9h6g5j+^qL=CI97 zlkKG|>#jRqdUJ69izC+I|Jv`(oq29`M#zhgwo+d%z5ckiX`$lEiHUt1T3>rz-gw-( zsLlWIPWKHD8RXAjT)ScI+AQ~75ifK-Cn=fUaa*}ZQ|-#GSvNBZqW-K{wd`j}?Ea0r z%u?)cn#MXxJZ<6Z{&6K_j&{6yzP$Kco9Cjlm~5OjgxtI}=|@fMoOYItwK_Mf=BGO) zPpqFb>99t=ap@nKjTf3L7Pp&UNqFtBzuuizLDgr#4z>scv>-Q~i2v zXXq_;4!&oS&C7PxPW!jIcEJXn2PGZ9`7+jC_mh}&!r1vtiB_NJohNtALV|-zEIVFq zT+KSyT4>{}s${3u#Gf|%xo|bDS5Noou0g!eoN+ z!T{!C8|j&?nI+^Fo-cPSOFQyYCFWTLy8>e&(*w=--? z-1z_5?VVG%9&Oq3>p@0H@wt}71>M>!l*^vi=S-N^#VEu7^v1NT=lX9#o*Aaq+UXf> zjgZ{>#P(0??JKVCujkBnu8hAg$maRqWNXK=JIpG0s*4V{xl;wHTrg#1gid)}wZge+X@%5%<#op?mlM^P|E{f^U zEX;}CZX~+M{KsMI?y&X?k{(+ULau1Z?0)gU-fm%Yaaq-+r#ii>o9@T1HyRG3xSp9{d4R<_NyxRDE zk3;LZkN+o_Jx#7iTRBO?XvK}4n=hnY31pvE@;O(*qVKTYd)ZrD8~*HDU3lc`s~HDN zWz%atp9D#l27lSVQ?m8-kBi?H@Fl9(SG@bb@LuJaok7oE?2(DM5xYF%v+~LJ?-z1v zrtg-5@Rw;wsJD(C%OvTw=B`{x4rr3wt3tm~);W1MJ}(YkcP_WRaqCIpnb&5n z^31p2X2Z06b7|4>zhRy}|GA8>3jAGqlc$dVseyFX#fdBHEsW|cv{LSz)lcuaUK!)z z_M)J~t%mPr-TIQ1H!3#?hy1U7@~8BlXz8^@c2A~!+5cYrhfn0$1lx5BEnckKV$>Yr zbB}GBBI9Q5%MA-6?Uz%wFZsZkMEKd!|~X-k+wKH{Zu}@1Ofy zcb!?aqq)HIP6`&2-8_udSLi<3;G(!1VCcpjO% z=Hm+gr-p8yQ?~kuJKu~y`q_1h%z~AeVT1vwb|oTak%*W|Mf{PHEN1Z{+W^KKRr(JPLax?txv`0MEU9^3d#Ah~`Y|gyh zXT0QF;B~cs=@)&rT$|PMtaw}L^Q?`E)N`k8&b}sSPv6*_~8_j{c&mUR;=gYesyue z4D}7l3A2iK3AA4fe6Ah(c$0R|9d^@sTekTrD;AiSFTBbAeND4TS!9h=gje7VG1W;K zr;e>H74i_^xv~G`&d6zRed9G=P4ZFr_Of+ztIBHCS6P0wHEsTPFa5~AX1_A4?~7D> z)rCUVs5>!XLYh@e%u}8^pZ(5Ucra-O_xXvBDpWX*Et=nUS6xz8L-fq@_ITyU_8)vR zcK@#4dHl`9>hISS&nU;eD9TeOWJ$;e>|SJYsNF~>+N@Qt7O!FFR{CP_wMd* ze#;qO%b7nue(CUB7PDRHiSLgeTUyTOms_w%j{VI2st7-P^k%npLX4|5xb~ z{r<1}cRX}lxtCL8r;J8wwE?eg$GteSIN^8?wr%oh=}X)m7*8q))b#w(G3Cn7>wWz^ z8HLLutc)}iA9!mQxCB|Ao3O$A>ED&F3nKLOb^Lmd#7*aP*f3o@#lx}SX6D@VeUB?0 zzZf!PrOy02)9d+rz9q~WjVy;5Z|Cy<&zXL#cZ+Iq?{w9(Z%WNf5$ZOtOo4hQ}gVEA1uLW|zBO+>Euh+ML*%o-)nRDW|vR_B)~5YgP4~_f~C}W^I4! zvvsmf-!hY)b5|~Dul{5G!&>j|>ia*QU!MH(Vf-(t`WJF@g}x`nUsjqhzm>J%)Ymu9 z%gz6~zSl3SJ1J~=-*El9uT|eOm$#ohm%VR-Klcwyt95=1*{|=ev*&kc;_!$F5-}+* zahrHpz_sgB<9vqq*N?yHnCHT{WJ&sr2b!N`j_I*4b6cHg{_5%LO%A!0uiwqgIk}~4 z#p@${KfI*xM3kjo+miY%IsU@;OOx!|Hkv;FeXS@>O}btp^Z=`Z;}oltqRgC+SSK@W zc$dEa#r?%oza3h%p0}Pag=s0DpZS`58~%Q2S-I!;`G+MPTqmco2`o}gT;b-C^4X(r zZNa~iH(QDynms??t-rR|w{qEJqe!L|=H51q7g^GCG`IJPthc^tvmocn)<@6v4#oWR zZVZ~hrfpfZx8>&~*X~B8$&8$W`xGCkI-G3mWohaEV*2;O}|DtZK{>E4K zReo)<`O4C|;^;TO{in76cJ-~fw|AC>;==o%bEluFH$8AZcZUJrzwZy1Sv)z+YP~LR z(}}a}R_{2c++Ef8K5Oa!-Tn1H9ZC*-3l8|9BlWJtvU2S~%Ze9=<*!HIZhP!;_vA^H zvmC*mOsj-y7Q1~&-?GholA3VbTHips|H^%SkGEM{U7x#4&7A+<-+QMX@Apn+S(zF7 z>0II;_PK}qtu_~ycF&wD#W8QXDc8Ge*+*sxWSN~2QB3}!(fRhcp8Lg9)||Co?_N&o z+}rl$ZTx@FESs%eW!X3X@I-!X6exTrS5hMwkXYvA^!(YA(Dk*)T+F!V&9_Vyh%GGF z%6WazHA4H$@_QmW2J8p3A`FAsn_M_gG(7tI=iQn!%=Yywt1XLuMeVIsp7Ct?+`V%m zV{27)zpjzF_N5^-=15WZS;5Fim&;XY=~mYnMQ4A$3W#^|&4$;s^hIO}KZrqX7KH*Jg`z84&s6tUAs-l4sj%W#@4 z+t>F@rsvPFZakjwclWea>1j+8B2D)AY3$vzR%2cG;r-9HIW2ctd{N=h$=tZGjY}4! zEYDCiJ5{*1=>5W0&-Nhs9rI3l?cBj^_y}E)eCOF1sWk>*VAr!L~a`b8P!N zT%}_Tx18NmWp{FNbH$(2E9+Hf{_6>R@2kGM^;pQ!BL?ML3~szhV@r1T;c>p|ll(O) z$&%4=V~t76+2^dRM+y~#vwqn;>3S{s;M~%0Yu;GL{hfWU*5c@|LtWPMuOB*O`F3}* zP|)tyGmZz}27Q~D7$+8%cYKM|Vvg*yUdPVA(4AWv+mds-b?SYgQ+oN@zb@`~D>z%a z^IRJ5&cv3};JKbVp@f8Z@9>2mb7T;7kJ z%S7yE&WYR{fA}A>Z_wt>i<=y+_9v`Actl8{@%!Becc%SuT=kMIFxu|pMlaWj1?SG5 z$(`(GyJoGjP3H7w_S0WNe{3u~S08_iRq^?<@;}SsE$3v_CLY~=ez$^62E(qYg+{w2 zI;_HvpVNu|_2Q(_ZSKBr@196Axh+-uRl|@KIrYz{zh5TZ_+`3n;jt!x+T$fm-F#QB zKA3sPDs#_@@*6P{O{qeerYD}veLnlwnrUxD)A$~WEM69CE4csI>>0Bv6`a5PXnL2r z`K5#DzOq2=ip6=~-x*YCeXL5heYnr+d8pmeHEMS<#F?!F778$N%5?RmKis@l`4zva zYn1HXLvwdWv1LU@b1sQ3V0hLTuziW}ryiw~9ZESZPG?UmxJX_;_I~o#v?ohv1(yC? zr>-#3`+Z%+o$Y5XWd5D(v)8q_GG}gpo~gca*MhGhT<;Ek6us$<)exVL7m zUWLxp7X{xfelsgeFszMtedp^*(biL*D%S7KUGTM5QfWi^oZ|G;3868}jT=q}Zl3RF zZF}(VkBxKluU)P`FntHJ{idkPfnD`+p}q58YMh>2Hcc@mHdOBM+@-($r}3pSY_cm? z@$)bXyz|ZD+J1?HYQb>d~(j^~A&|3u2L`&1uYYSo%4u_z|3!=k_4($3J4Dm$dHQp3q$J zMAxnO=lPxd^Bt8#!sCrk-dldYp4n?bjm5&Bx#30cHY_iB|9|n^x%yh>Qgg5US;k*` z{OW$+?K1yf|JuIA$|T6IZP9+yx3SVj95;VBu)3_teZMA{Z@Xqfp7@WDhR!oj{&6V= z@cD13e=l+8+omlA=YGaL zJlA&ZpVA(!Nn3@#?Ph#AZ{qX=IoAS$gLh?|NfTJ($)dG^t>mteU-zbBzs{J}UAHFe zVYpFqV=4cJ0|}AK7EUZx`(w1VRp(hxK(O@*?Vmx1jy6QTpS1B5bJ%MIldt;rxs1Wl zAD$@NriB0dw$(1f`2pL{v-{1Py#8;fQPwGBz2>oKs>$6sNnRWF96n{fNzdG*NLxQ< zdg%eR?Nu*?iqG$`6u%L{u5#nuv+w`;XDQUbj`s?^`#RRn6wZAeE~-kgkDv1w0bLg1-wozY$`r=`q7e1|5LI5`yPJy zeVwmON`B4*^WR;`ou~JlUdGend|jn3{C%hPKEJ~GWeC1p7GMe;$F9`jAt-^@Od z!S|@>xYgb1EKZSEN~XJL!=C+8?jk7>|4W4x!sn>bF7w|ZCf9GHhTGnc{46u ze3V{gu~=vRy*k6s6;~%Qb1xClXL9_ZJ-hb5ZfRVVeIB3P^q?t4K99`pdtce57xqoO zw)*JjDZZ!Ita97_|MZIY9XnQU*Q@)w=Z8UIBfHfa1q&xm!~8uBr7&LN*~h!N z`xpAPv)@y{UO&07% zB>ZM?-OtYSiNQmFu~a_!fa$Yxjyb6@_UFZ?Y-L-0A}8x)SP%Qv*R zRpn&=jN!`K*!PZoKcn_v-L>)RhYiJhp7fO|o|&r7$f*!8^Xre0H?#dS!pIXth~q);|C!d#IGgCS+BmVEStGXTO@wzQ~3>2ugdIdS06UdVVbb$DsRG^ zZxzc7 zlnM!M|Nc-aQr~QuSdxNeYPP(k<l-+-hMSLTlH5x=iA%4>#xt*qgB4Zt?;+2nA^rx zO~D8Gmz)+8v3>sc@ORry@m3QKNP0Ieb$s{JblQZbHEeH^&Sk_a?O3H#we$C_=Q5F} zt!=JN>lCh=`_@k&u{p@-W}{3z|F-mk2v@(yYEAEr9Cv>He0R%_bDuebKmD3Cv!46* z?f8>R%$I)MYHXCYnOWZX?khRb&^s+pm)?2C^S-xT>R@NYfn4?%jUY4i{Dp<{3_V_o@rNc zxYO<*+m>`ZD~$5`?sL26aK|TRC1>s$&dQD>CP(fc|G!C&Uw*4j=Kou>8|Cx^jch)g zYjRm`btCE0Brp9bhFgW_p0oWSnitKP`^b8d)2!zz($P~b?$?VZ&HJ`z)6LMTBQG}Y zb+!Clu9xm{M}2{;#GG2UnykP#JboKByPr(7`KI{IwVHeXj59&LdqpZ9H1155o_SI; z>afypx6@JV-)(rg_uH66ux`;_J+1k5_50e$|Exw#r=0H#*DjrUwtjZ zO>5{(N|# z&u#5`bIq1J;t^tjW?Opq7pXY@{nb)w_0Mj7bU|V>{{c}MKby`)dv3kn`R$6PethZm z?RDYH|CDdvzVi7p!QXRcGR=M2qbhCj+xC*+YR&S?+ukc?nad>Atdy}i*?B#B(a-Ir zjL)X0EqiFk!JW9^!yKmFtOcyI9#79)w_hh#aMeGhTc3+Rwyk+SziZPLHF>eR^A%a8 zXFg`0nSU^D{zgrn$XPkk>K|05 z?F;^A-&?%nn%uGEp9z~UF8XzNuGQCX4GTnnW%ZOw%~7A$CvOtB_5A`r*7OR7$JOD| z#aG(Z_-0AGzr8xxE+y&L)zhy+AN-HoC3yMC@~H{V;a@BSzq6*vEu6gYbkWnr3l|rh z*=u6`B`1E54~o!9W3Ab{%E79vBT+i zHy4zZPSIF$?DAJm=Hmh7GmrfeII^am%g(;M^1#vyUMsW{_S-EEVB<6}PczDJHJWjH zN4{ua$jZBN%T6@%6?yW#%KmZS-~69v?&kfzTBzz5^S0w;2E)RVs$%6*$yhOU?MP4LsvjG? z^__F_hyO(N)bEcbN9r%Sb5}X(XmQcaj&Do<$DTX=_^WX18HfBeo2A>PF4@XBW7CXf zZ?E;cEZujS*TzDl%vREN(ca1<%=#-&)|dtTm^J0w+OpfnGgakPuFG?6Sg@?(bnq+B z_tN*1=S)fq+taopHbKyoN$kMJVn_8wzO34@lLNKaPP)E!b!1*Fv-F*(ZoWs{cbd0O zK6LWV>?7`O)pok`L)HD%E~}wY99Wruy-v2Es^+8T_sOTVehM==%#De43gViwK~Zhf zlYM&%R=F@{Ejq#zIweK>->=-7nxBmSmWcdwS-wA|mW4_FgGcavrR0|?Z1yTI>fc`K zpS=9LtzA*Y5&!(V$K>Nnr~RJ0ZugAYr#^h`ahbUHs9waDrG>LI1Kb^R&%4Bb?c<;O zzqzVl>jtyzU;nDE2ItOImo#0pa`)?y^Un?2{~D?6%<|k8p)2?-CF@gBk%@slo6D^g ztgf};g74`C} zQq*2=?A~+q(}8_r-qYtDe|=>;!-NNXDh@hR1t(2WJInLr#*TK&oj-$Tu5kOw>Gw&k zZqom6JGnb=UJNp9Ol{>p*z1(^^jq|1@73;KxU~OWU66QmUe3ummK&6rl4_VeN|r>_ zsVG~v%$L~^tkn8HpXbrnoeK|MX`E8@bgq8(tE##wp*2@3zxIo(^GFMvl~bH>?Z<=b z%11u42EJbOTSsFxv+{wKhR<1kU4j?q+HY^X(k#77c5zCR?LAKIg_k0e#8MO6)9xRh zyS-xO-_9u$+K#pHTzcBqx=#O~+1LA@#C=-R&IL_wP~z!c-ND7XazWZ@z0*5_oC*?x zM5byUx72fyWPQsoA$lsCC(u}ZsRnel@y(tz5R*EF0qr9pA$U)op_S)&tuVsgp7(9D>lwV?u@hX zVcY)qGu`~@sb;1y>+yC?Ri!Qsm6rOfA2p9YUHyK&-1V3E)Dyx6$w#~0-Zd}2w(a*6 zAJHR1Y&RWMs>Ik{PVT-rsZwL|EU{N_PdW)JT{f4jb@tr(HJ#&9(_xl{lTJ-|%JJpJ zQK_Y>dv@Guo6XW;QOvljPhXH_`}z8+^5;*~-~M=i-z8!3)|XP2v8L(&=1bmO`0$HP znTlDv{A;oOi8;rPsXzH^s(ji$UI+ zduIP0ewfsC(_gdc!JI??+Fa*ckdv|AIsaU)=#oyhbp_hz-Mm=@6D{{-q^#LnzGPR&#MEmQi; zntgwt?+x^w>%MS%!m2$v-&ZaFEPg-Y{_AN|D~kU*F9`0f`gEvx>i?fz72j(Qc*$8Gjw-O*v~_T~nHR z_M*(Wn{%|=tG~_24!av*S8(L?(j6a?#H3?e{13HjRBW8srSWd>5}ALCrBAM2V>j*a zvWV{DXFs%-{_3&pfBpDySHzloI|?7FazB1|QS5;KUfm5drj5Dp7Omu%=Po2=8>1h&rdTw&iOEJqFLWP#}t9@ zx(yqPmwPFQOp?7gPeNM!dxDZgk=~!rH%^!RtI1S2&1N3SI3s0tpGe7WWATIg!~d}= z-(G21dEP?!lx9lhw%@E%!Vg;A3SB$HWpC3QksEfEU%J0(M$Xh{i71+I$hD#MS=SP@qr_&54VN?^p*GIXl)JhC|PSA@Q&l>6xKhttGxGxK8SM3-2A!y z(nGV$$>nq9*7Dwb`0(w;+aFJSzj$3g{{3T(Q{VN4?(KejRW@{2>!I1k(|=?X%+0X) zXK8b>;!lhG%Yb<|-!p!dmR~=uq_%&J?wS3@hmGwX@QLU)%w=IzKIfU7`(nY)1NYtv zPj#9pt$fk=yU_l=UCUgoEYG^mT~RD*^VPz3n&>mOrbp}Yd`f@c4H5U=J^P|iYFyHt zRr|7zdi_sa>a|9tRL(p7Vej4AgzO2HGa1BcIG@FZg|^05?dX4hRkn`5u{>X7^XYdJ zHGkZAsQ;opuvE{Yc2)aw)itb#oTUZsS@5^qn78xU`Ty@cf}XAXRMGwS?}|+If6qD7 zCs(sKT;29y+9Fr(K7J!@zT)_-ddvEc#G4=6*DuampUacJ zcD`X=Czb|xiZxz)~*LY^gxb|mq#(eX)*58>+1N@W@L$ROKG9u7&w)QOFv8X8jL$A66VP z3psl0!NOdbZA<4A-nz!xK9#lO$ujj1%#RuUPvz}nPcNCi+N<@{@BhZQ@V|GWiu20u?7)Vy&petl!i=U!3tieaHT+rB?OUR;{KjjS{m%9~ zux;w{=R$wu9ZfalBd-3+UUxRll6Bc$&j$OZy5~;S0-(?J&3wzn zX|w-*U1`d%Uds1gA|j~g&9ANB828Sy%lPt$;oN&2-qcj@3$K2Dsmlt=p8CqZt?ftc zzXJ>RfB8CB`1o55wK(z4n{RX1rOrtyU$bPo{-n1{>~y{VtXq~?A=Ekf*&Ex)p2@#L zl_t#&k!jqqa%*+qm2WOO7wvW9b6;&XI-8}b-TMB-sqj?0Rkte>Tus}%FGkA+-#5Q* z=~Tr0=XRKDo-8Gl`}^0M z#4>!?yFbQ8rs2K4(xUg%vvTG}vN1RCF?0OLoAY7fchf(I4({elOx z|3BI@^`W!n=a4lfb;0}2WGC1=c1GF?*qxlYDJalz=lOlfm#&w(2P)5=R9^9UwsP9- zzoCl`?U(K3v3(#m*<${+MCJO9)N@u-j=a7#U+>_J{BHpt4r_NU-{yYuTRyM*>-!qj zdu(=TXIB?0E-{Th^zV@W{w0ffR~2B)atFF zQMcJkcVtH|;&E%gljwA_!s(Z$YzM!)#0%}djKku0Vvk5NH-9|Q-mksaCGP!&iCXuT zvA^#$Ubf5PQ%k>v=;adjs`=9%-k9AJcCG%`-pHG`!=o&HroQhyr5L*O-<(w+bSqO; zw3us-7&n|RC|mvfSV{J4CjYQ`$8@4sFFER>pR|uLewJa#qsY5{D@yIRymg-bRp;70 znS$2=O0Sm9SiMo$d;71FXU(iL-tpwwbF4XVPe^}7j$o9^t0<;3U$^dG8`{XY_T|y1 zGhcbK*M`;iUiE!$vmn~|!=BggKIrYzJn(tDsrcH0tlxQ`zyE!Fyy=3%Ocx)fD^Ks= zDc$n*H~U+=c|O}-v!u3N$-I*LRnL4;?ro#Jubqw5t*-bx`Ky<|{h8}{TWPoa*2U^K zqd%vb?0mX5yh?i7g|4F^){~y8?L69@U#T?v#-qJuDSy50JnP!!lB8``Xq%rCy=+dF zUip$OtE(^WJ|TDE+MJZ)Nh!OX|EQgLuDL!UA$jF1wv831E|>oI`MFDNUYDmN)A5HX zvVqaohxEh`M&cCCDQ~!$d!t!qto-w3$*+(65~-PgeYp{L zb*-=5$LRT;^WJ>jVz@ak*n?X_^Q&UDy_Mv#9>w5yhk5^$uFTvi8U0B3$g@1X?CGMx zjP{ngu6q*Qj&vOL6;(T!x{GmU8)s=++le{C4P47a^)6Xfb8oNseg28Xl4WYvT3H%N z8HEqlNiR;=l2G)x+ET-`)BVBmbhVAfF_n7j;_L#Ccr5BKx&5~BkkX@RpMGdQOIll( zaXw|!?$ta0eA#eB^I%U{maYFWU&UptBAnfiR4r9lA4fl*xz|!D=ZVSF>7q-yCf}eo_0Jmv64gdfE literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/Merriweather-Bold.woff2 b/frontend/public/fonts/Merriweather-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a6919a9da3c168b2bf8dfbc8555c0018860a38a0 GIT binary patch literal 19740 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

    *=u73xPNb~d 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 y8PnegeY;c6l$rbg_o*=M@2 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/RobotoMono-Bold.woff2 b/frontend/public/fonts/RobotoMono-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d7170daca1a38b071f13c03f47611f92bb8e0e1a GIT binary patch literal 12276 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 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/RobotoMono-Medium.woff2 b/frontend/public/fonts/RobotoMono-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b4f2bf8c22481b36ce7f9079d40155fa0e31ff42 GIT binary patch literal 12532 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 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/RobotoMono-Regular.woff2 b/frontend/public/fonts/RobotoMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f8894bab50f641e86f695e85830a74dd395732fa GIT binary patch literal 12312 zcmXT-cQayOWME)mFpyy21JU7g7#Q^TLBv3OR4il0mS7(o;uzw=&|)UY!PPi3;io8f zvl9cO8IuZgvlEK~Yby)80uQ_NbeGLaH!K($7WL|O743Z4^xwJ3#(CCTF8u<%=x;uq zYgaMISoF#Ycwg1t`sA^p__@ose#>jmi%~gp=4nvh>E1p5R#A;H|H5PzZuBo&xGM4W z&5|WAf`r%>7F|$qRFd)V)$5)HeGq=XT4N)-@>V>26x3+sycYx&8jRs0FOko=;?-bC2_%<+HsPcWjDWX1x8D z&>qL?72lapaZF`y6;aV+U${8%{W>wX>g~5KFXTIsD(k%~dGfcyc(py}c|@39o7nFP zzvAKX{B)6hVe^G^hfnj!+;kMPI+<&fR&?fg?NOHCw=x1*XRT%{MNXU^qg~V(XB%@Y z?0xB;&X!Bkir=%87(?Yk1@klshYyDt>ssHi&bY|`%%1VJjQbq!qIY+bk2EBIJk-DM-Fvo%Ol7e>3<|T) zvnb5Syb^q(Pmn41(#n=FtL%40+KLbUUl(HD$joy>Y>&oVM;V2^4Axu|3>W(?Z(z}~ z^IpiA+CFEeq|v6{!%kLA7Y?}eY}apfo2$kxuwCfQioZ$aZ~rw(E|NFwnBi2RR3qrY z!Y0_3pv2PQbwtlz<&q@l-1r&t8{b49P7qL1TNe`b^%z%ZO?tPQ;5&}@+)9cP$@NSQ zj4$Sgbeto-rA1s~~en~!>d@cD_^0UVu%uaof_?m2PaV-}>_5CvNTOMV0C6OVeYXajffZd9>tf_VwHsuc!0mNXgu? zFf-a!SyA(Q+FI-FSKrN;H)GDNr5%Rv=H0a3?Y`^%g@4TK{W4}}U1k5iyzwzu{gvOp zqF-mu?|1vp=9reo@Pg?A{|Dw})d%iqKHyK7?syVm{*xrq=J?GhUeOLRMq$vLNJI=nO{$N{i)cR?f*PYIOJNxUTarcjv=?4>*ADp7}WZ}yT9&hFtJ^FXV#O-3c z%K1sheJx#^j9vts($ZF3d%Ebi6K8y96zjC@mp{IXSrpu~` zbjF5r^QP{SGF;J|`2N9pn+ts6TP88Fc6$VdJver&$aRuty3W%DN97NRD;X3ouKu%2 z<=hRkpAUEK*R`+s#`m3ok=tAM#M;z7)3P~92L=)?p>pjKX2lxw8>I(JmDLUo_k$czRTQd zo28xix~?BtSqiR}w;UE|cID=VE)w9sz^}9U?Y=WnEn3U=&#iiQB752Ixig$(qc?C) zoS`^DO-5)&?D>4xqPKf2qF%82*&R5@ke~cvPHAt@8tDwZ&VD5hxj=b0R zj-36q{P)J1L`J!55tA9cRy=?C!oG5O=NU2cqu=vVpY8sVy~+NW$LCUxsT)Ln&mZXi z99tM??jxlV@GkgZ=Sv%fATP&CTNwlUZ#B~r4EN5+3@FWG?kIU_b=qR3)Z;VdQ*JB% z+j=`%O!^8lgGEM6iObZ;9QG78TYgWS`ee)FpC_v&UU~1ocg}I`S+@?|tF|x?@e5Vm z+q;VY-o_2z+RJ(sCM$6=%wT*K+o)EZnq~UZcFV2uNA8?=)~?NEIV7f=(YQ5QEx%p& z{;M6A+63$Ee^#C@*zbRtPkMFf)pS+&`}>^|I<6S2u6ncO7T+-;1&>w#+4Qey7_GWF zL1@38&y9h9<$9w`E0v=? zXXduI9of9Gt9^Ha<5ZosiacqTdJCO7qSRz#UL^9#-K*WUur@?O>1k^>-?`}ROZR*d zt=V3FFnKTQ%8*Ak2W}~N=R55$ZHnA5DZKT7bNKznjlK4478#qq`!+9hV->d z+zHzy-QGKd+gqDo`MjN(FL#d^50k(h50AJ6-WQsGp6&m+NMw(mZ-qzWg(d8J!v%zQ zaIbP|)k)f3yFx*jGh=pR%@dZCJzZyA@})U`xF6bVE$~ID)A3Jcuwe0YJDr49j}&=f zEh_=u2`+0Ao`-xkdVKIqxJ^)NN3e%i3wz79MLJ3?_TS|h_RnlomOQrSy-V+-7S^7i z`8PPuJe!-u$65E-_Lgp2{PMXx%f7p8)hf@s@gqe{Xld)Y6@l+do+xmx*rgk%Y_RiH z{drG=&(j{sS{G%nmpmTc;p{E9)Nam&sNY)CnpL@vt<@JQb1h0=!}2=RUv8bVp#2J& z1&<#@B(D1D?=8_;EmM?IU@CI%*tbO6vmW+rQzqtYX)A3F3hy{~=Z&|h!G|ix>5F-M zqBHL7+xqBw0UQ6SD}0PsKhMv2+Z#CZWYxChvfT{q3*$fcY8dCd>8NIX$1E6;+>&VY z;QAzfroIDR%BKIbH%gRj-wsMXWHI6FEyBZFIjbsnTdki^($XZZ>M@F z{o_t`NU1wE<7+1S-47S6ZKa&5Y(9j>R=@C@=)|FaRYKL+MaC<$^U@|EaXqoV7q3p* z{gVt8tq|I@>es>^_M`vo4lkdV7kOBsV5LXGlB0_&zF22XHb{0o-lS$$c0##g{eu*_ z&bQX-bzW*&8`oWFPI=8O$*$r0=*fOZ<&ZTl+FFwOCzjR~755%CzI5MVz0#D^yOtci zT$s{cJWERN_IKvvj0dc3OV~@K%Qq%-_6Ztw{LHXr_q&QK~My=q&hX==ETvY60 z*X=Ct4EJ=YvHEe)OP+uBk(<30rETZ7FKXjh;I`p@m74s+UAN?K+T=VqalS{Q#^uSz z79l^*kl&U9uZ5U?EEUX26u23x!KlE!$~`09g5j=Wf9y0Ho>wh-LAz9X*eCJ6NlSjU znUQ(#$ICXTao{UqpMn6M3E-G2*TkS4i z6j56gmGxlm$>^8J`(_{~F(s8t>1_F?v^K@ZT1%X5K&Fztner zDbMkOo4?l7-pUl&VDPpkDdz8Q--g8xI11jZd+~n8J4v<=QKj{+8LIIUcPV!jh9_&V zOz2*~dHvVkkO$l`b{m^7DjF`={LR(OWGP`*k||Q|lp(DV8O9lXG2ma^?yr2i_R2lJ z^!){Y;vYWd$@eqvAItKXe4xbirjN(W=~5faJMJudf8*o~qc>YZlV@Kwt=cvBYFJ0< z2i{v8Srx0QudTUzT4n>w=>zYst}f$T$*`!AtFf;@Ggwq-nrT8K<0$*j|T!v72po@>hgXU`_FZ}nZL zXx2&2Tl92onN;H^fBuy+C&tnE70(W}=1H;F?Br7?-DuN$@Ki9vx|}h{VBVTuzX0CH z4ySrm6nQVRIZ4ZHZH;11dAdd3vuxY1^=3hw+L5W#mKy}FlCBWAFi)-KlI+u@5GyhlJ8Xv^6liWX1AZ+o18K2fM@ONLJ^f% zha907nuf1dB)m2Fy>PnT64|*2U%NJ^E_wI)$HD_kYfRrg{(In1HtQp0=DL^a%#KPo z9Jf`4CkZ@#!a-Q=TtA+V(y#d{@hA7p@-1Uz%!i zQ9F7TY&D+3QQ2j&<@zF}ydziUJN^B)^+t3yzs)YoWsg%#Bi30tnItmK@SW9i{rT*s zYt|0p9P+!Ww>9ip?04;waKY<5q3jjn6H9+TzrS9P`2t_w&8vIamamvJL!fX|Yp2zP z{!X*Y+KZp4GG4fSaK|If%Z?NI#Ew3$R{9X3zdtfG!5~**p`VA^-9#2S``fAorER}f z`^1<{TlK>IonK_1a!O0?F`YO~%kzcX=6sm^P-x+UZBw?ZC;6KQKa85>=zGtL^EPAS zyrow*9QzY(bd>YB=u?hV-OVy9O?7o&nCliMCoeqhX*|_vf#-9!6UqW?4QE)BB?Gvxp@%G-eQ45}J|8PtGeA_kOXV?7BPyN2`wxy%x z{E6~qZ`VmVu$+$G)%@?#M!O|Tg);5@PF-i`WMz4~diuAgSGYrdf10k}8O3O+-JyO~ z{Zz`q)N3)5WBYP`9sHXa7x*eF=y^fS+~qgC-&iswoo|ddKXI@Aa*KUsJBnpBRr9;P zU$;HVazkV~_c!4qihmX?ikiZs`EC9+5eD0|Yw=y7Yh$Lh{N2bnk>gPx&lhfo*6-4K zMoX^*Yn0?N{82hkyi)nF#l@YRC(KHCUa$+wA9pvo-rpC-$en!o#QA3pSFhPk`uOon z#^g^+Z7%V(*v~a+z9)Cg$wkU;+X97qTGP0ToOnV__RgN2y!5F_Y2nfB`3obxmMXTW zpWzJRD*S2OzJ|9e_y0d**_m^%xl7LU%=k2^%G36^!yA>3&$6GV{_vPA-7kCJ!H>-| zK}MS=M)5g=*#?7i)3i^gPYX1De5x?v_aTY>J*uZRUfTF&(w9Pqm*#aJ4~iHsv2>AB zw+>L@ImKz2HCOd+(eLlFYjO@xdGLO}%H#=a^beH1Ubxo(k6r8E`yW!cu37$mZJ3Z~ z^G!TMicL&UEg>;SmND<|G4TV3cErC>`28z?-(yqvcAtlJ35hj33%Fe#6}dB53Qd)$ z{L*1jSEBY*gwwt)U7shkNo0{*;f`AZT%o5v zewr4mxkB*p)`Yf$OM_yrM8w5i`O$hpe~Va=bcBR&|H}{GTN$TDDpuOWd6c=jGCs2v zu$9eVG?cq-^JLzqAE~-~U%!0$^3KgoW_dH8uc^-2df?*Dqs4WHHr<=HZ_7WM*VjMz z`+EQ1dq=unqWXzV#n!Wzw*5LM?bPQnr&pZIVrqoXnaa<{_RYHG=KE{*_1(!wm6+oc z&K&FH^Lf4g{f)5Zc^30`TrPW}G&$~6TJq{6M+2iPMC?-Kk21_;6FYYJWAhJxn}DvY z?Bh?nDq7ZTD|P)hopJM46D6Om%Z#s2*3$TXe}Dh9?6dZ1)8tG~EcM>GpR@St?oOsf z4GW@HUq1G&(9`DC4vlMf7g{`cFRwF|S$KV(&Z$oeKNM~3?A-BY*<*p)I~McLEkEy; zwo2`!jZnYH{pk}0_s{Zv)Y8|r=#J&n#?RavLlgw9`Y*ZrGjc!wYjc*Nv9(a1V>kE1 z1=$f7SwlANm~bp8A&Y_i!+R;lS9%5<-#6{hxtCX2_wL;ak2sa^J?hdsdaC6O{=Ip1 zzR4@bb=|W!@dx%FfBWZc8UO8_x8yfBOQq}n{^v1wQ}NH@J9!u7FQ1%NTQBL@;4Ru( znSOk#E&scZEin&sA2-W}ziXQEdeiRfdxWpe`uWc2bMF6h`?y#R?>O~z!a3i?`yUG3 zR}?r_!9J%9SrEBPz$h{hgPR?KV*j7n7Ae5Q==Wx?lVr@F=FJxtDS4{FgmKTVeB z8B4K~i|k3JqNQi|uXYZdQu1P#nd1w={oM~uuUM?_oI9^3XM+CsYq#=R(ys4qwF)iU zB`z>w&H}IX2WK+YoqE%*wWVVo=h~Y4Aty{2y$w=h*;k*c33;cU^z_%6zl&CVn{qMs zHs5lm&a##J>y~M}&Oeb@k<|11`ucBrC~i+e&Q^moBTE|5!~ebs_H7o z=O_M?%oOHrmahv}1ZT(%iy zRr+p~JXz;C`Ie@3OMpE#??2jR~bY_?o!JS=$mu5mZ(%|~9&jl}`q&V9J=yXP(I z3eMN78s_@CbJy-@%M@C4=2Y7ai8#LMZ+~WAu6|y>&c44%aCw(N?uM>~GO~3RKId}& zXP-P7w`BgMiT?UOGb}DIJE`>P#Uj_$onCwz?zMZ?F@5_z^YPOEQ~K7*UC!I{rd4~v zXzG1(WpY?rBYdY`c%~QXe z-FavAy&Wc2dl}oa7hN_!(Efhz{r`XBY9Cw?o?DmsdYxqg=d)Ffe;p0ts*;YZx7l!k z!QH5s{ql@?k~L);o9DkjTK;~%Vso$KG&8Y?@2x-i>pqlo_uDHr_uR+`*?D?*3d>&C z2T5lF?$4=cjn*#Uo7P;j&2)xgq%w29$CZ1D4>jXH=Rd4Exg~Sk1e=_+ZL=2C#B{~{ z*t6+O-Dkdl?#9rM^{L*Mjz7G*L82so+p3<$ z0aJ>XaV#@u6SM9MN}axWv507nfTsD4tv6GAciQbTzOQUlZPKW;^zr4o)h*X9oeNUY z|NLynoew$R8{Gw0UJISPJ$8ZN2|2B;EsGW0cGuq9oWHSm{_W$zLen3-5ZX4O>&-U) zGb$BU$()u`Ld5Uhf3!4BEupadlvezo+%$#Ly)$dv?cBJ!{RLn zZKWkW6JAaVb`HDXd+5Y7;pEF_lFnM>Wpo6qKD^AU^^_y@y;k{!gZ}Z_&IjJ#`C)ld zdCo+p|Ig(`_)~@4ub7(U@tyX%FA)`fTdxPuVpsPxSjQ_!mrBT@BPY~TaOnjmn1))m%E_g(DLuktZs08D!ZVje?O=5OXDu) zGvB?vHJF*td~ry5G}(JY@)9*C>1!n?kKgyYb-6wJMNM;|LH5_150thrXPYIgSG?uj zF14|$f2uOGLqc2dy2SfD)7?DM7kPP0=(&Vl|1(?3Nl0<_)TvY6O|y32@kD;@{B&DS zx%rznN>>|3M#zP?xBW`)UVEhb8F!PL-tLTxZ#sOI2+N1(=q{AlJJq{Eb%TN4!I;@O zFKn5c*x%{ik6aKvYaK`4qgBektgN=VKACc^qkmp{zUORVh3*wE{y1d1uwA>!Gt-f` z@mPP4R`-oXzx`wn#LDkVoVh{fUXauxVGhH{g$vnOX3y!CaNJ?Jl8otzg0Qyt ze}wFLr?>g37Ja&?bim^0v+MIWF1HuqJvRH%dEO&$r1q4u?%930{auBT?b*%8ySbcF zi+rX!^sV{IWA>mj^!04n*S%(u7n#HT`dZY&{k&hyNxL=g&Z7^tXMJb4w#|_G!m`aa z?f?9SOB;_D{^<5vnUfKaCKvL2^ZKGil8;V4xipEBb*}&Oi{De#g_n8O2-U0X>zKlH zIrPKH#bPt(Fi*VmxIN{xjQyU6uQU1hJ}i~t+;mPmLjKfJ=@VA6d3)8044vmqTJ(*v ziO=)hTitap>e_=FzR#I+GIoF7T@F><+8IIjj>XOQ{Cd&PN2}s~)}*8V|9{XRQ69Z?4 z*;8c?h$ryY-7E=t#pI!&c}3;PZ`BhbY|Jyi{o5<$xm4|UGylJp3yM~sE}Q=M|0@^6 z(6^6{c-HZL*EMfDc3&($%1Y4t@nzO8&6b`mcU2fY4dz66oNsvf^}6)gGr^pfnIfOd z=zo)oKOPfd6n}i;0;z&~zYO%0yO;U%NM3NWFj)K~({ta0(+XE2qi+^Xp2ss|m#?m^ z@m_0}MWt)!De34sh|11wf56$j(W*8<6x8OVvn`&d__LnIC!RLuM=K}aet&yqr;lR-pT3^_ zNzrLPO-tJ~&K`SnC+5HUnMDt$i{GnfdZOn#_xrL~3)5zv{{OGtt&|?^J@huq>Ql;t z8~KqMhyL8*441evUAv*JL~QEI-EPMlCRc_|Z3!rUml2$iy-bqpAQOT=1yl4W({LiRlQj+=KP^%{u7%O98>;(>`qR}JL);f zf5*-FqLznqceOrxl=eR$WXsy{Hvftx_vHP*gtwL5{>T1|@zCbi_wLV6^t&EEXVrXz z-%Gyk?{_#mwNOy6c)?5EoqKtt>$9Sy>yO%))@{fR2rj+w<#={+wNv|5?sYnO-z<-{ z>zAFcXy&@TKk{; z#C!Av^F}FtHPf@)(h>?s%PsHD{{6q5|Igd|&-HfiShzR$f79)_*)sp@JLk$7Fr@!s zkSj9I3uxG6@GofjLZ-9!eDVHHeoR&cy#B>}ANT#Y&O5sQ)t+5TP!q8Y@R(%kJZw;I8$XMSCpMVkj|a#R=$#t;z|#~?HA15;rRMy zo1)S=yDh4I9h#z@+)YQ1NNjiQ{^OjpJlgze<^1>G&lkETJ~+59Q2d;_f~Jn%gH6rM zbI#rW)ovN|%~|V9Y^ZgS-RZ?fDNSS$8inG45Cv|H(zG=_Xx zc4w*gP0oXc@7_sThUuT+id@?j#9~ z+AkBicK>?o)p;)MLEKm0bwT&yINOgu_3R5t>Hc1@Q|4V=f15*xHVID%q^TS)!2I$({wj4=4fwI)sn==X{sd~uO|5|2|l^U#A#-}=v%&ofW@2< z623ecSO1;3zTIN>rOTGH^Y-78k7Si?ahu-G;i@4!wdoSqlvKrw0SErSU|G9#j;i;G zNfUR(q>1GmR*tNj!WkiJa`UODT%P%XLz~T21D2#{KHYv;d!b>St%Q%DdY8!af2r@| ztpJD0+iDwO^`WunuSum3WubT?){)+VbX4&W4g)Jt7as7n-<^6_?gI9op*2M?#Cgm?~@G59(Qi$apT`` z@vhO`qP5CKmL+V>&PV!{MpS!p}@` zC%U%1<^0J%_kH{V;~-X%#Er+egKr7Vo+fox{;Nl^_t#yQUtDeRv(;VmQkbJK+ zUE|1JQ9jdm(-Thy^d@xMFLzlfdvr>bX!Z)HE{Aj%@dclq|1l@FU2mRy;8nr%2h0gO zg(_A=vB|gXYHyBxn9iN>Q*`&V_|DYnTX$S>^=o>y=&$Gwu9}bkmImwW{$=5(6wOnp zd*0}Emu=&7R^KDOYh!O-+PY16P0ri7Jx6mnA3kD@NB^=&-a^WLkjpiRB7-v-y8x9ci@vfGI6 ztNU1V;=2BJ{eP0aN6tQeWgzm8^Ke2{slXeKw$RgyAC}+wr*Zy)Y{di5=RZH4o-L-c zs;JQLwV#eb8RO~`|9&tseD3zmTXy)^`eo}IK0Wa;eD(M@|HcfT@Pq}E4GcJ6t8g7D zOT4gg!gu+JM?>bfB)>PUdvoGUg12`_-I7_or#5fkDOxf~cw@mm_np}ur{`N0CUvVA zAD%qTW93$>e_uKa&WT03JrmRPNS|G{a!Y98^P@$9S2Rl_Z+dRE@KQNnbhTzjvC__n zGNa^!Co49FD22wcXsu^cHJZ4`LvPZ<&s#rD){|43aIyZ$};RX)SEf9hIhXU|*eHpom9dhkYMg5N@xlpPk!6Aquqj7S$TTRiX0 z*}|rpPoH)@V{xpge%N?9RDXTH!10U+*RQARMa?evtyZqjy~woRaPB7c{r9W0*8kpi zh=khn+tzP89HvP)lRUD$n zw?F>(r~AsXBUY#8w;NpA!Sgxd=j;m&n`JXLFqqVYU&H2_bPvUy9i1*F<98>pNoyF@uCe4hI zpFEp!`;8pC6D)g7@@`#yW7l%SO5k_A^d2MU+SlK5?_bIdOwo*V+L5*|DRBK4Wq(OY z<5ickel1OOnq=_hyQ{Cmqf1^6Yh0{n?Q*}C*u4Mi%9AyPqVI3G-imvj#NC>lUD#%! zBT|`bWBk6l@73A`_v6z7lztT0)f~@T5-O8)^US*pq12P-%`aBxmaVH#xbS;dyN`e5 zQ6|H3v*yijE@^9))xBY~NPRs^0lCdYrYng1rTcGAj$FG0r|~@|5dXT%DzZ zzWy!us0aaF|0lmB>VD5zTX{l#^~T6GK6%Iew|zY5U%u|(o9cOTA5tZBRfL#CS-+|l z1iJKoZ~y-A=f!qK@5$Reom7|X6o|bTr^IfrA2snviU0a7)z!cD$mhEj^z}Ea{nWST z`N3PV3W`ycAK4Cbtra!QbJu_UaAW5`rmZFyn(bSrL~178Wz6A=keIuQzw2M!>6u0; z#moNx`@LX86mMMK?b27R%=a7rf9}ql?q7D+q3Ts-yYMmR8FT76OLt~2dyrh;spGS{ zxBS@W?h~!N2Tk|zx%aT^d)CT5-4 z$JIqPiQ3r4J=(1=^!x%-mf?o4mG!SxBA>rrDt*jo&PsXR<1Ur!=dBW1`ev(*i|759 zI^oB{r@ua2AfvCvU!%?=?UjCC>B-N64A7SJejtd&TEG1>PyM8ji+Z?)_#|uNnE&E!R>i zC3fRvOZNHy1oPK_eJYf^_gqZ7d3x>BzfT!UrcXL@^1jxYOXp{Ao5eOuNPOwCbu3qw zf7rF{YVOxu0nyag{`RJAZtf!1o9CLvttd$epC`b@%Czs5WADv|#BT@aSUV=c>%#$9~1{{_^Pl=ELz? zt@(Yy(fE8XtI6xexy*;M#oE_?Tv*H_F=chVnfit!r+-iHiTvFY9s2Z2WNc+w?!3!iOe+?8HL+oSDN>2xv^)ngR!W?<}Hb@BE6SQ z+?t{j;l0d#bMCp0$Y{5$&2u*vOx>Cqv{kilhSS-No1cY>cC|k}ILqs_R3rBV3nop* z2W+d-63<3Yetfq1?NJ6PKE`(fDp8+v&P`o-L9xPF~Bm zr_lBR%>XYxh=NVg`6z$!+Rl0A7X2aftZOD(-k{eZ(h^l zjxRQc9j|-jaZK`j5O78zeqxG{g@|o-ch1Kr=i?XVicDI1_|i_9YbKv&X`fW`c+SS% zaf>behH1cOMbp&IhHonCJw2a$oDXz~aM{end#J|6H}WLY@|shY3{ErmtN&)OjgL!D zuna0+r)Vd?Y}O_prfuqW*7xpnoZqbSdj?-UH?h;`;w=`YXw|LM)Zn zP}6i;(c!eO4^Hnow7}2!Tx0fu7cw5^i_hiF^_+P%*Isr}5>xWET|85aPRL$ddPVN< zR!ygx%F}}NE)pCElOA~b-pIWqR(tBl$(Kv*nvPX=_`N?n;Zcg&g!dDAbBe@I9Qm?s z{#*Un3xS@k!ilEW6#AB3(YIT8?!Atw8n5+&LwUj9te*X^_b^aA9nG!ask_|f*andY z0lV4a7#HnuENpBpPCLxwqWVDe^!8_WbPlW9hHZGBFqL7qJg3|CNNG#XF!9tK3tl;G zt!-YM7xeH=q4$CRuU?2gFh01nwkUQ3`x!1V@mW=&_AGwfj>Xa|pNM`ix^TEIpTWgp zZa7o%oo}ZST+YR@e(8M2+mv`zzmH+>t7HcT9>;S@*58&LJ*dV}_{#sUW{s-XRjujj z?(4ibR9qHn2nbowtpJ*npe(~U?|Aj?5nNug-i9MlkJ0)=`7t6^wZK?Rl4&kLAc^%${SY8|cR3cZsbL@b*$eF@)c39qG + + + + + + + + + diff --git a/frontend/public/logo-wordmark.svg b/frontend/public/logo-wordmark.svg new file mode 100644 index 0000000..8ffb931 --- /dev/null +++ b/frontend/public/logo-wordmark.svg @@ -0,0 +1,8 @@ + + + + + + + Inker + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..c604b16 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ec192fc --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,259 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from './contexts/AuthContext'; +import { NotificationProvider } from './contexts/NotificationContext'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { ErrorBoundary, ToastContainer } from './components/common'; + +// Auth pages +import { Login } from './pages/auth/Login'; + +// Main pages +import { Dashboard } from './pages/Dashboard'; +import { DevicesList } from './pages/devices/DevicesList'; +import { DeviceDetail } from './pages/devices/DeviceDetail'; +import { DeviceForm } from './pages/devices/DeviceForm'; +import { ScreensList } from './pages/screens/ScreensList'; +import { ScreenDetail } from './pages/screens/ScreenDetail'; +import { ScreenForm } from './pages/screens/ScreenForm'; +import { ScreenDesigner } from './pages/screens/ScreenDesigner'; +import { PlaylistsList } from './pages/playlists/PlaylistsList'; +import { PlaylistDetail } from './pages/playlists/PlaylistDetail'; +import { PlaylistForm } from './pages/playlists/PlaylistForm'; +import { Settings } from './pages/settings/Settings'; +import { ModelsList } from './pages/models/ModelsList'; +import { DataSourceForm } from './pages/data-sources'; +import { CustomWidgetForm, CustomWidgetPreview } from './pages/custom-widgets'; +import { Extensions } from './pages/extensions'; + +/** + * Main App component with routing + * Per scaling-up-with-reducer-and-context.md, we wrap the app with context providers + * ErrorBoundary catches any runtime errors and displays a fallback UI + */ +function App() { + return ( + + + + + + {/* Public routes */} + } /> + + {/* Protected routes */} + + + + } + /> + + {/* Devices */} + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Screens */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Playlists */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Settings */} + + + + } + /> + + {/* Models */} + + + + } + /> + + {/* Extensions - combines Data Sources and Custom Widgets */} + + + + } + /> + + {/* Data Sources - redirect list to extensions, keep forms */} + } + /> + + + + } + /> + + + + } + /> + + {/* Custom Widgets - redirect list to extensions, keep forms */} + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Redirect root to dashboard */} + } /> + + {/* 404 */} + } /> + + + + + + + ); +} + +export default App; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..4e1a66a --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,29 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { LoadingSpinner } from './common'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +/** + * Protected route component that requires authentication. + * Conditionally renders based on auth state. + */ +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +

    + +
    + ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/common/BatteryIndicator.tsx b/frontend/src/components/common/BatteryIndicator.tsx new file mode 100644 index 0000000..51f9fb7 --- /dev/null +++ b/frontend/src/components/common/BatteryIndicator.tsx @@ -0,0 +1,212 @@ +/** + * Battery indicator component for displaying device battery level + * Uses hardcoded colors that don't change with theme + */ + +// Hardcoded status colors (theme-independent, bright/vivid) +const STATUS_COLORS = { + success: { + bg: '#DCFCE7', + text: '#16A34A', + dot: '#22C55E', + }, + error: { + bg: '#FEE2E2', + text: '#DC2626', + dot: '#EF4444', + }, + warning: { + bg: '#FEF3C7', + text: '#D97706', + dot: '#F59E0B', + }, +}; + +interface BatteryIndicatorProps { + /** Battery level percentage (0-100) */ + level: number | undefined | null; + /** Whether to show the percentage text */ + showPercentage?: boolean; + /** Additional CSS classes */ + className?: string; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Show animated charging indicator */ + isCharging?: boolean; +} + +type BatteryConfig = { + colors: typeof STATUS_COLORS.success; + label: string; + fillWidth: number; +}; + +/** + * Determines battery status configuration based on level + */ +function getBatteryConfig(level: number): BatteryConfig { + if (level >= 75) { + return { + colors: STATUS_COLORS.success, + label: 'Full', + fillWidth: level, + }; + } + if (level >= 50) { + return { + colors: STATUS_COLORS.warning, + label: 'Medium', + fillWidth: level, + }; + } + if (level >= 25) { + return { + colors: STATUS_COLORS.warning, + label: 'Low', + fillWidth: level, + }; + } + return { + colors: STATUS_COLORS.error, + label: 'Critical', + fillWidth: Math.max(level, 5), + }; +} + +const sizeClasses = { + sm: { + container: 'h-4 w-8', + tip: 'w-1 h-2', + text: 'text-xs font-medium', + gap: 'gap-1.5', + badge: 'px-2 py-0.5 text-xs', + }, + md: { + container: 'h-5 w-10', + tip: 'w-1.5 h-2.5', + text: 'text-sm font-medium', + gap: 'gap-2', + badge: 'px-2.5 py-1 text-sm', + }, + lg: { + container: 'h-6 w-12', + tip: 'w-2 h-3', + text: 'text-base font-semibold', + gap: 'gap-2.5', + badge: 'px-3 py-1.5 text-base', + }, +}; + +export function BatteryIndicator({ + level, + showPercentage = true, + className = '', + size = 'md', + isCharging = false, +}: BatteryIndicatorProps) { + const sizes = sizeClasses[size]; + + // Handle null/undefined/invalid values gracefully + if (level === null || level === undefined) { + return ( + +
    + + {showPercentage && N/A} +
    +
    + ); + } + + // Clamp level to valid range (0-100) + const clampedLevel = Math.max(0, Math.min(100, level)); + const config = getBatteryConfig(clampedLevel); + + return ( + +
    + + {showPercentage && ( + {clampedLevel}% + )} +
    +
    + ); +} + +interface BatteryIconProps { + size: 'sm' | 'md' | 'lg'; + fillPercent: number; + colors: typeof STATUS_COLORS.success; + isCharging?: boolean; +} + +function BatteryIcon({ size, fillPercent, colors, isCharging = false }: BatteryIconProps) { + const sizes = sizeClasses[size]; + + return ( +
    + {/* Battery body */} +
    + {/* Fill level with gradient */} +
    + {/* Charging bolt icon */} + {isCharging && ( +
    + + + +
    + )} +
    + {/* Battery tip */} +
    +
    + ); +} + +/** + * Compact battery indicator showing just the icon + */ +export function BatteryIndicatorCompact({ + level, + className = '', + size = 'sm', +}: Omit) { + return ( + + ); +} diff --git a/frontend/src/components/common/Button.tsx b/frontend/src/components/common/Button.tsx new file mode 100644 index 0000000..a1e95e4 --- /dev/null +++ b/frontend/src/components/common/Button.tsx @@ -0,0 +1,127 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'outline' | 'ghost'; + size?: 'xs' | 'sm' | 'md' | 'lg'; + isLoading?: boolean; + fullWidth?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; +} + +/** + * Reusable Button component with monochrome theme and accent color + * Uses CSS variables for easy theme customization + */ +export function Button({ + children, + variant = 'primary', + size = 'md', + isLoading = false, + fullWidth = false, + leftIcon, + rightIcon, + disabled, + className = '', + ...props +}: ButtonProps) { + const baseClasses = ` + inline-flex items-center justify-center font-semibold + rounded-xl transition-all duration-200 ease-in-out + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bg-page + disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none + transform active:scale-[0.98] + `; + + const variantClasses = { + primary: ` + bg-accent text-text-inverse shadow-theme-md + hover:bg-accent-hover hover:shadow-theme-lg + focus:ring-accent + `, + secondary: ` + bg-text-primary text-text-inverse shadow-theme-md + hover:opacity-90 hover:shadow-theme-lg + focus:ring-text-muted + `, + danger: ` + bg-status-error-text text-text-inverse shadow-theme-md + hover:opacity-90 hover:shadow-theme-lg + focus:ring-status-error-border + `, + success: ` + bg-status-success-text text-text-inverse shadow-theme-md + hover:opacity-90 hover:shadow-theme-lg + focus:ring-status-success-border + `, + warning: ` + bg-status-warning-text text-text-inverse shadow-theme-md + hover:opacity-90 hover:shadow-theme-lg + focus:ring-status-warning-border + `, + outline: ` + bg-transparent border-2 border-border-default + text-text-secondary shadow-theme-sm + hover:bg-bg-muted hover:border-border-dark hover:shadow-theme-md + focus:ring-border-dark + `, + ghost: ` + bg-transparent text-text-secondary + hover:bg-bg-muted hover:text-text-primary + focus:ring-border-default + `, + }; + + const sizeClasses = { + xs: 'px-2.5 py-1 text-xs gap-1', + sm: 'px-3 py-1.5 text-sm gap-1.5', + md: 'px-4 py-2.5 text-sm gap-2', + lg: 'px-6 py-3 text-base gap-2', + }; + + const widthClass = fullWidth ? 'w-full' : ''; + + const classes = ` + ${baseClasses} + ${variantClasses[variant]} + ${sizeClasses[size]} + ${widthClass} + ${className} + `.replace(/\s+/g, ' ').trim(); + + return ( + + ); +} diff --git a/frontend/src/components/common/Card.tsx b/frontend/src/components/common/Card.tsx new file mode 100644 index 0000000..5f4ebf4 --- /dev/null +++ b/frontend/src/components/common/Card.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + padding?: 'none' | 'sm' | 'md' | 'lg'; + hover?: boolean; + onClick?: () => void; + variant?: 'default' | 'bordered' | 'elevated'; +} + +interface CardHeaderProps { + children: ReactNode; + className?: string; + action?: ReactNode; +} + +interface CardBodyProps { + children: ReactNode; + className?: string; +} + +interface CardFooterProps { + children: ReactNode; + className?: string; +} + +/** + * Card component with monochrome theme + * Uses CSS variables for easy theme customization + */ +export function Card({ + children, + className = '', + padding = 'md', + hover = false, + onClick, + variant = 'default', +}: CardProps) { + const baseClasses = 'bg-bg-card rounded-2xl transition-all duration-200'; + + const paddingClasses = { + none: '', + sm: 'p-4', + md: 'p-6', + lg: 'p-8', + }; + + const variantClasses = { + default: 'shadow-theme-sm border border-border-light', + bordered: 'border-2 border-border-default', + elevated: 'shadow-theme-lg', + }; + + const hoverClass = hover + ? 'hover:shadow-theme-xl hover:border-accent/30 hover:-translate-y-0.5' + : ''; + + const clickableClass = onClick ? 'cursor-pointer' : ''; + + const classes = ` + ${baseClasses} + ${paddingClasses[padding]} + ${variantClasses[variant]} + ${hoverClass} + ${clickableClass} + ${className} + `.replace(/\s+/g, ' ').trim(); + + return ( +
    + {children} +
    + ); +} + +/** + * Card Header component for consistent card titles + */ +export function CardHeader({ children, className = '', action }: CardHeaderProps) { + return ( +
    +
    {children}
    + {action &&
    {action}
    } +
    + ); +} + +/** + * Card Body component for card content + */ +export function CardBody({ children, className = '' }: CardBodyProps) { + return
    {children}
    ; +} + +/** + * Card Footer component for card actions + */ +export function CardFooter({ children, className = '' }: CardFooterProps) { + return ( +
    + {children} +
    + ); +} diff --git a/frontend/src/components/common/CustomWidgetPreviewRenderer.tsx b/frontend/src/components/common/CustomWidgetPreviewRenderer.tsx new file mode 100644 index 0000000..0ce2542 --- /dev/null +++ b/frontend/src/components/common/CustomWidgetPreviewRenderer.tsx @@ -0,0 +1,359 @@ +/** + * CustomWidgetPreviewRenderer Component + * Shared component for rendering custom widget previews. + * Used in both CustomWidgetForm (Live Preview) and Extensions page (widget cards). + */ +import { EInkImage } from './EInkImage'; +import type { FieldDisplayType } from '../../types'; + +interface GridCellConfig { + field: string; + fieldType?: FieldDisplayType; + label?: string; + prefix?: string; + suffix?: string; + useScript?: boolean; + script?: string; + align?: 'left' | 'center' | 'right'; +} + +interface CustomWidgetPreviewRendererProps { + displayType: string; + config: Record; + sampleData: unknown; + scriptResult?: { + success: boolean; + output?: unknown; + variables?: Record; + error?: string; + } | null; + template?: string; + fontSize?: number; +} + +/** + * Extract value from nested object using dot notation path + * Supports: field, nested.field, array[0].field, rates[0].mid + */ +function getFieldValue(data: unknown, path: string): unknown { + if (!data || !path) return undefined; + + const parts = path.split('.'); + let current: unknown = data; + + for (const part of parts) { + if (current === null || current === undefined) return undefined; + + // Handle array notation like "items[0]" or just "[0]" + const arrayMatch = part.match(/^(.+?)?\[(\d+)\]$/); + if (arrayMatch) { + const [, key, index] = arrayMatch; + if (key) { + current = (current as Record)[key]; + } + if (Array.isArray(current)) { + current = current[parseInt(index, 10)]; + } else { + return undefined; + } + } else { + current = (current as Record)[part]; + } + } + + return current; +} + +/** + * Render a field value based on its type + */ +function renderFieldValue(value: unknown, fieldType: FieldDisplayType, fontSize: number): React.ReactNode { + if (value === undefined || value === null) { + return --; + } + + switch (fieldType) { + case 'image': + return ( + + ); + case 'number': { + const num = Number(value); + const displayValue = isNaN(num) ? String(value) : num.toLocaleString(); + return {displayValue}; + } + case 'link': + return ( + + {String(value)} + + ); + default: + return {String(value)}; + } +} + +export function CustomWidgetPreviewRenderer({ + displayType, + config, + sampleData, + scriptResult, + template, + fontSize = 24, +}: CustomWidgetPreviewRendererProps) { + if (!sampleData) { + return ( +
    + No data available +
    + ); + } + + try { + switch (displayType) { + case 'value': { + const field = (config.field as string) || ''; + const fieldType = (config.fieldType as FieldDisplayType) || 'text'; + const prefix = (config.prefix as string) || ''; + const suffix = (config.suffix as string) || ''; + const label = (config.label as string) || ''; + const value = getFieldValue(sampleData, field); + + // For image type, don't show prefix/suffix/label + if (fieldType === 'image') { + return ( +
    + {renderFieldValue(value, fieldType, fontSize)} +
    + ); + } + + return ( +
    + {label && ( +
    + {label} +
    + )} +
    + {prefix && {prefix}} + {renderFieldValue(value, fieldType, fontSize)} + {suffix && {suffix}} +
    +
    + ); + } + + case 'list': { + const arrayPath = (config.arrayPath as string) || ''; + const itemField = (config.itemField as string) || ''; + const maxItems = (config.maxItems as number) || 5; + const listStyle = (config.listStyle as string) || 'bullet'; + + // Get array from data using path + let arrayData: unknown[] = []; + if (arrayPath) { + const extracted = getFieldValue(sampleData, arrayPath); + if (Array.isArray(extracted)) { + arrayData = extracted; + } + } else if (Array.isArray(sampleData)) { + arrayData = sampleData; + } else if (typeof sampleData === 'object' && sampleData !== null && 'items' in sampleData) { + arrayData = (sampleData as { items: unknown[] }).items || []; + } + + // Extract item field from each item + const items = arrayData.slice(0, maxItems).map((item) => { + if (itemField && typeof item === 'object' && item !== null) { + const value = (item as Record)[itemField]; + return value !== undefined ? String(value) : JSON.stringify(item); + } + return typeof item === 'object' ? JSON.stringify(item) : String(item); + }); + + // Get prefix based on style + const getPrefix = (index: number) => { + switch (listStyle) { + case 'number': return `${index + 1}. `; + case 'dash': return '- '; + case 'none': return ''; + default: return '• '; + } + }; + + return ( +
      + {items.length > 0 ? ( + items.map((item, i) => ( +
    • {getPrefix(i)}{item}
    • + )) + ) : ( +
    • No items found
    • + )} +
    + ); + } + + case 'script': { + const code = (config.scriptCode as string) || ''; + const outputMode = (config.scriptOutputMode as string) || 'value'; + const prefix = (config.prefix as string) || ''; + const suffix = (config.suffix as string) || ''; + + if (!code) { + return
    No script configured
    ; + } + + // Show live execution result + if (scriptResult?.success) { + let displayValue: string; + + if (outputMode === 'template' && scriptResult.variables) { + // Template mode: substitute variables + displayValue = template || ''; + Object.entries(scriptResult.variables).forEach(([key, val]) => { + displayValue = displayValue.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val)); + }); + } else if (scriptResult.output !== undefined) { + // Value mode: show with prefix/suffix + displayValue = `${prefix}${String(scriptResult.output)}${suffix}`; + } else { + displayValue = '[no output]'; + } + + return ( +
    +
    + {displayValue} +
    +
    + ); + } else if (scriptResult?.error) { + return ( +
    +
    + {scriptResult.error} +
    +
    + ); + } + + return ( +
    + Script not executed +
    + ); + } + + case 'grid': { + const gridCols = (config.gridCols as number) || 2; + const gridRows = (config.gridRows as number) || 2; + const gridGap = (config.gridGap as number) || 8; + const cells = (config.gridCells as Record) || {}; + + // Helper to execute cell script in browser sandbox + const executeCellScript = (script: string): { value: unknown; error?: string } => { + if (!script || !sampleData) return { value: '', error: 'No script or data' }; + try { + const safeGlobals = { + Math, + String, + Number, + Boolean, + Array: { isArray: Array.isArray, from: Array.from }, + Object: { keys: Object.keys, values: Object.values, entries: Object.entries }, + JSON: { parse: JSON.parse, stringify: JSON.stringify }, + Date, + parseInt, + parseFloat, + isNaN, + isFinite, + }; + const fn = new Function('$', ...Object.keys(safeGlobals), script); + const result = fn(sampleData, ...Object.values(safeGlobals)); + return { value: result }; + } catch (err) { + return { value: '', error: err instanceof Error ? err.message : 'Script error' }; + } + }; + + return ( +
    + {Array.from({ length: gridRows * gridCols }).map((_, index) => { + const row = Math.floor(index / gridCols); + const col = index % gridCols; + const cellKey = `${row}-${col}`; + const cellConfig = cells[cellKey]; + + if (!cellConfig?.field && !cellConfig?.useScript) { + return
    ; + } + + const cellFontSize = fontSize * 0.7; + + // Get cell value - either from script or field + let displayValue: unknown; + let hasError = false; + + if (cellConfig.useScript && cellConfig.script) { + const result = executeCellScript(cellConfig.script); + displayValue = result.value; + hasError = !!result.error; + } else { + displayValue = getFieldValue(sampleData, cellConfig.field); + } + + const cellAlign = cellConfig.align || 'center'; + const alignItems = cellAlign === 'left' ? 'items-start' : cellAlign === 'right' ? 'items-end' : 'items-center'; + const textAlign = cellAlign === 'left' ? 'text-left' : cellAlign === 'right' ? 'text-right' : 'text-center'; + const justifyContent = cellAlign === 'left' ? 'justify-start' : cellAlign === 'right' ? 'justify-end' : 'justify-center'; + + return ( +
    + {cellConfig.label && ( +
    + {cellConfig.label} +
    + )} +
    + {!cellConfig.useScript && cellConfig.prefix && ( + {cellConfig.prefix} + )} + {renderFieldValue(displayValue, cellConfig.fieldType || 'text', cellFontSize)} + {!cellConfig.useScript && cellConfig.suffix && ( + {cellConfig.suffix} + )} +
    +
    + ); + })} +
    + ); + } + + default: + return
    Unknown display type
    ; + } + } catch { + return
    Error rendering preview
    ; + } +} diff --git a/frontend/src/components/common/EInkImage.tsx b/frontend/src/components/common/EInkImage.tsx new file mode 100644 index 0000000..a08f625 --- /dev/null +++ b/frontend/src/components/common/EInkImage.tsx @@ -0,0 +1,195 @@ +/** + * EInkImage Component + * Renders images optimized for e-ink displays: + * - Applies grayscale + high contrast filter to simulate e-ink appearance + * - Freezes GIF animations by drawing first frame to canvas + * - Handles loading and error states + * - Transforms /uploads/* paths to use backend public URL when configured + * + * E-ink displays can't show animations and look best with high contrast + * black and white images, so this component processes images accordingly. + */ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { config } from '../../config'; + +interface EInkImageProps { + src: string; + alt?: string; + fit?: 'contain' | 'cover' | 'fill'; + className?: string; + style?: React.CSSProperties; + maxHeight?: string | number; +} + +interface EInkImageState { + canvasDataUrl: string | null; + imageError: boolean; + loading: boolean; + currentSrc: string; +} + +export function EInkImage({ + src, + alt = 'Image', + fit = 'contain', + className = '', + style = {}, + maxHeight, +}: EInkImageProps) { + const canvasRef = useRef(null); + + // Transform src URL to use backend public URL for /uploads/* paths + const resolvedSrc = useMemo(() => config.getAssetUrl(src), [src]); + + // Use a single state object that includes the src it was computed for + const [state, setState] = useState({ + canvasDataUrl: null, + imageError: false, + loading: true, + currentSrc: resolvedSrc, + }); + + // Derive isGif from src using useMemo (no state needed) + const isGif = useMemo(() => { + if (!src) return false; + const urlLower = src.toLowerCase(); + if (urlLower.endsWith('.gif') || urlLower.includes('.gif?')) { + return true; + } + if (urlLower.includes('giphy.com') || urlLower.includes('tenor.com')) { + return true; + } + return false; + }, [src]); + + // Reset state when src changes - using useEffect to comply with lint rules + useEffect(() => { + // Reset state when src changes + setState({ + canvasDataUrl: null, + imageError: false, + loading: true, + currentSrc: resolvedSrc, + }); + }, [resolvedSrc]); + + // For GIFs, load image and draw first frame to canvas to freeze animation + useEffect(() => { + // Skip if state hasn't been updated for current src yet + if (state.currentSrc !== resolvedSrc) return; + + if (!isGif || !resolvedSrc) { + // For non-GIFs, mark as not loading via callback + setState(prev => prev.currentSrc === resolvedSrc ? { ...prev, loading: false } : prev); + return; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + const canvas = canvasRef.current; + if (canvas) { + // Set canvas size to match image + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + + const ctx = canvas.getContext('2d'); + if (ctx) { + // Draw the first frame (this freezes the GIF) + ctx.drawImage(img, 0, 0); + + // Convert to data URL for display + try { + const dataUrl = canvas.toDataURL('image/png'); + setState(prev => ({ ...prev, canvasDataUrl: dataUrl, loading: false })); + } catch { + // CORS error - fall back to regular image (will still animate) + setState(prev => ({ ...prev, canvasDataUrl: null, loading: false })); + } + } else { + setState(prev => ({ ...prev, loading: false })); + } + } else { + setState(prev => ({ ...prev, loading: false })); + } + }; + + img.onerror = () => { + setState(prev => ({ ...prev, imageError: true, loading: false })); + }; + + img.src = resolvedSrc; + + return () => { + img.onload = null; + img.onerror = null; + }; + // state.currentSrc is intentionally excluded - we sync based on resolvedSrc, not state + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isGif, resolvedSrc]); + + // E-ink filter styles - grayscale with high contrast + const einkFilter: React.CSSProperties = { + filter: 'grayscale(100%) contrast(1.5)', + objectFit: fit, + ...(maxHeight ? { maxHeight } : {}), + ...style, + }; + + // Destructure state for cleaner access + const { canvasDataUrl, imageError, loading } = state; + + if (imageError) { + return ( +
    + + + + Image error +
    + ); + } + + if (loading && isGif) { + return ( +
    +
    Loading...
    + {/* Hidden canvas for GIF processing */} + +
    + ); + } + + // For GIFs, use the frozen canvas image (first frame only) + if (isGif && canvasDataUrl) { + return ( + <> + {alt} + {/* Hidden canvas for processing */} + + + ); + } + + // For regular images (or GIFs that couldn't be processed due to CORS) + return ( + <> + {alt} setState(prev => ({ ...prev, imageError: true }))} + onLoad={() => setState(prev => ({ ...prev, loading: false }))} + /> + {/* Hidden canvas in case needed for GIF processing */} + {isGif && } + + ); +} diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..54098e3 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,129 @@ +import { Component } from 'react'; +import type { ReactNode } from 'react'; +import { Button } from './Button'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: { componentStack: string } | null; +} + +/** + * ErrorBoundary component to catch JavaScript errors anywhere in the component tree + * + * Error boundaries are special React components that catch errors during rendering, + * in lifecycle methods, and in constructors of the whole tree below them. + * They must be class components as there is no hook equivalent for error boundaries. + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // Update state so the next render will show the fallback UI + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: { componentStack: string }) { + // Log error details for debugging + console.error('ErrorBoundary caught an error:', error, errorInfo); + + this.setState({ + error, + errorInfo, + }); + + // You can also log the error to an error reporting service here + // Example: logErrorToService(error, errorInfo); + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render() { + if (this.state.hasError) { + // Custom fallback UI provided by parent + if (this.props.fallback) { + return this.props.fallback; + } + + // Default fallback UI + return ( +
    +
    +
    + + + +

    + Oops! Something went wrong +

    +

    + We encountered an unexpected error. Please try refreshing the page. +

    + + {this.state.error && ( +
    +

    Error Details:

    +

    + {this.state.error.toString()} +

    + {import.meta.env.DEV && this.state.errorInfo && ( +
    + + Stack Trace + +
    +                        {this.state.errorInfo.componentStack}
    +                      
    +
    + )} +
    + )} + +
    + + +
    +
    +
    +
    + ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/common/Input.tsx b/frontend/src/components/common/Input.tsx new file mode 100644 index 0000000..11b04a9 --- /dev/null +++ b/frontend/src/components/common/Input.tsx @@ -0,0 +1,96 @@ +import { type InputHTMLAttributes, forwardRef } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + fullWidth?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} + +/** + * Reusable Input component with monochrome theme + * Uses CSS variables for easy theme customization + */ +export const Input = forwardRef( + ( + { + label, + error, + helperText, + fullWidth = true, + leftIcon, + rightIcon, + className = '', + id, + disabled, + ...props + }, + ref + ) => { + const inputId = id || `input-${label?.toLowerCase().replace(/\s+/g, '-')}`; + const widthClass = fullWidth ? 'w-full' : ''; + + const inputClasses = ` + block w-full px-4 py-2.5 + bg-bg-input border-2 rounded-xl + text-text-primary placeholder-text-placeholder + transition-all duration-200 ease-in-out + focus:outline-none focus:ring-0 + disabled:bg-bg-muted disabled:text-text-muted disabled:cursor-not-allowed + ${leftIcon ? 'pl-11' : ''} + ${rightIcon ? 'pr-11' : ''} + ${error + ? 'border-status-error-border focus:border-status-error-text focus:shadow-theme-md' + : 'border-border-light focus:border-accent focus:shadow-theme-sm hover:border-border-default' + } + ${className} + `.replace(/\s+/g, ' ').trim(); + + return ( +
    + {label && ( + + )} +
    + {leftIcon && ( +
    + {leftIcon} +
    + )} + + {rightIcon && ( +
    + {rightIcon} +
    + )} +
    + {error && ( +

    + + + + {error} +

    + )} + {helperText && !error && ( +

    {helperText}

    + )} +
    + ); + } +); + +Input.displayName = 'Input'; diff --git a/frontend/src/components/common/LoadingSkeleton.tsx b/frontend/src/components/common/LoadingSkeleton.tsx new file mode 100644 index 0000000..a9d6450 --- /dev/null +++ b/frontend/src/components/common/LoadingSkeleton.tsx @@ -0,0 +1,130 @@ +/** + * LoadingSkeleton component for showing placeholder content while data loads + * Uses CSS variables for easy theme customization + */ + +interface SkeletonProps { + className?: string; + variant?: 'text' | 'circular' | 'rectangular'; + width?: string | number; + height?: string | number; + animation?: 'pulse' | 'wave' | 'none'; +} + +export function Skeleton({ + className = '', + variant = 'rectangular', + width, + height, + animation = 'pulse', +}: SkeletonProps) { + const baseClasses = 'bg-border-light'; + + const variantClasses = { + text: 'rounded h-4', + circular: 'rounded-full', + rectangular: 'rounded-lg', + }; + + const animationClasses = { + pulse: 'animate-pulse', + wave: 'animate-wave', + none: '', + }; + + const style: React.CSSProperties = { + width: width ? (typeof width === 'number' ? `${width}px` : width) : '100%', + height: height + ? typeof height === 'number' + ? `${height}px` + : height + : variant === 'text' + ? '1rem' + : '100%', + }; + + return ( +
    + ); +} + +/** + * Card skeleton for grid layouts + */ +export function CardSkeleton() { + return ( +
    + +
    + + +
    +
    + ); +} + +/** + * Table row skeleton + */ +export function TableRowSkeleton({ columns = 4 }: { columns?: number }) { + return ( + + {Array.from({ length: columns }).map((_, index) => ( + + + + ))} + + ); +} + +/** + * List item skeleton + */ +export function ListItemSkeleton() { + return ( +
    + +
    + + +
    +
    + ); +} + +/** + * Grid of card skeletons for common loading states + */ +export function GridSkeleton({ count = 8 }: { count?: number }) { + return ( +
    + {Array.from({ length: count }).map((_, index) => ( + + ))} +
    + ); +} + +/** + * Form skeleton + */ +export function FormSkeleton({ fields = 4 }: { fields?: number }) { + return ( +
    + {Array.from({ length: fields }).map((_, index) => ( +
    + + +
    + ))} +
    + + +
    +
    + ); +} diff --git a/frontend/src/components/common/LoadingSpinner.tsx b/frontend/src/components/common/LoadingSpinner.tsx new file mode 100644 index 0000000..eb8ef8a --- /dev/null +++ b/frontend/src/components/common/LoadingSpinner.tsx @@ -0,0 +1,111 @@ +interface LoadingSpinnerProps { + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + color?: 'primary' | 'secondary' | 'white' | 'muted'; + label?: string; +} + +/** + * Loading spinner component with monochrome theme + * Uses CSS variables for easy theme customization + */ +export function LoadingSpinner({ + size = 'md', + className = '', + color = 'primary', + label, +}: LoadingSpinnerProps) { + const sizeClasses = { + xs: 'h-3 w-3', + sm: 'h-4 w-4', + md: 'h-8 w-8', + lg: 'h-12 w-12', + xl: 'h-16 w-16', + }; + + const colorClasses = { + primary: 'text-accent', + secondary: 'text-text-secondary', + white: 'text-white', + muted: 'text-text-muted', + }; + + const trackColorClasses = { + primary: 'text-border-light', + secondary: 'text-border-light', + white: 'text-white/30', + muted: 'text-border-light', + }; + + return ( +
    +
    + {/* Background track */} + + + + {/* Animated spinner */} + + + +
    + {label && ( +

    + {label} +

    + )} +
    + ); +} + +/** + * Full page loading spinner overlay + */ +export function LoadingOverlay({ + label = 'Loading...', +}: { + label?: string; +}) { + return ( +
    +
    + +

    {label}

    +
    +
    + ); +} + +/** + * Inline loading spinner for buttons and small areas + */ +export function LoadingDots({ className = '' }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx new file mode 100644 index 0000000..d97b313 --- /dev/null +++ b/frontend/src/components/common/Modal.tsx @@ -0,0 +1,134 @@ +import { type ReactNode, useEffect, useCallback } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + footer?: ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + closeOnBackdrop?: boolean; + showCloseButton?: boolean; +} + +/** + * Reusable Modal component with monochrome theme + * Uses CSS variables for easy theme customization + */ +export function Modal({ + isOpen, + onClose, + title, + children, + footer, + size = 'md', + closeOnBackdrop = true, + showCloseButton = true, +}: ModalProps) { + // Close modal on Escape key + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }, + [isOpen, onClose] + ); + + useEffect(() => { + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [handleEscape]); + + // Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const sizeClasses = { + sm: 'max-w-md', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', + full: 'max-w-[90vw]', + }; + + const handleBackdropClick = () => { + if (closeOnBackdrop) { + onClose(); + } + }; + + return ( +
    + {/* Backdrop with blur effect */} +
    + + {/* Modal container */} +
    +
    e.stopPropagation()} + > + {/* Header */} +
    +

    {title}

    + {showCloseButton && ( + + )} +
    + + {/* Body */} +
    {children}
    + + {/* Footer */} + {footer && ( +
    + {footer} +
    + )} +
    +
    +
    + ); +} diff --git a/frontend/src/components/common/OnlineStatus.tsx b/frontend/src/components/common/OnlineStatus.tsx new file mode 100644 index 0000000..7b67351 --- /dev/null +++ b/frontend/src/components/common/OnlineStatus.tsx @@ -0,0 +1,307 @@ +/** + * Online/offline status indicator component + * Uses hardcoded colors that don't change with theme + */ + +// Hardcoded status colors (theme-independent, bright/vivid) +const STATUS_COLORS = { + success: { + bg: '#DCFCE7', + text: '#16A34A', + border: '#86EFAC', + dot: '#22C55E', + }, + error: { + bg: '#FEE2E2', + text: '#DC2626', + border: '#FCA5A5', + dot: '#EF4444', + }, + warning: { + bg: '#FEF3C7', + text: '#D97706', + border: '#FCD34D', + dot: '#F59E0B', + }, +}; + +interface OnlineStatusProps { + /** Device status - 'online' or 'offline' */ + status: 'online' | 'offline' | string; + /** Whether to show the status text label */ + showLabel?: boolean; + /** Additional CSS classes */ + className?: string; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Last seen timestamp (ISO string) for computing staleness */ + lastSeen?: string; + /** Threshold in minutes to consider a device stale (default: 5) */ + staleThresholdMinutes?: number; +} + +type StatusConfig = { + colors: typeof STATUS_COLORS.success; + label: string; + pulse: boolean; +}; + +const sizeClasses = { + sm: { + dot: 'h-2 w-2', + ring: 'h-3 w-3', + text: 'text-xs font-medium', + gap: 'gap-1.5', + padding: 'px-2 py-0.5', + }, + md: { + dot: 'h-2.5 w-2.5', + ring: 'h-4 w-4', + text: 'text-sm font-medium', + gap: 'gap-2', + padding: 'px-3 py-1', + }, + lg: { + dot: 'h-3 w-3', + ring: 'h-5 w-5', + text: 'text-base font-semibold', + gap: 'gap-2.5', + padding: 'px-4 py-1.5', + }, +}; + +/** + * Determines if a device should be considered stale based on lastSeen + */ +function isStale(lastSeen: string | undefined, thresholdMinutes: number): boolean { + if (!lastSeen) return false; + + const lastSeenDate = new Date(lastSeen); + const now = new Date(); + const diffMs = now.getTime() - lastSeenDate.getTime(); + const diffMinutes = diffMs / (1000 * 60); + + return diffMinutes > thresholdMinutes; +} + +/** + * Formats the last seen time in a human-readable way + */ +function formatLastSeen(lastSeen: string | undefined): string { + if (!lastSeen) return ''; + + const lastSeenDate = new Date(lastSeen); + const now = new Date(); + const diffMs = now.getTime() - lastSeenDate.getTime(); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +function getStatusConfig( + status: string, + lastSeen: string | undefined, + staleThresholdMinutes: number +): StatusConfig { + const normalizedStatus = status?.toLowerCase(); + + if (normalizedStatus === 'online') { + // Check if the device is actually stale despite reporting online + const stale = isStale(lastSeen, staleThresholdMinutes); + if (stale) { + return { + colors: STATUS_COLORS.warning, + label: 'Stale', + pulse: false, + }; + } + return { + colors: STATUS_COLORS.success, + label: 'Online', + pulse: true, + }; + } + + // Offline or unknown status + return { + colors: STATUS_COLORS.error, + label: 'Offline', + pulse: false, + }; +} + +export function OnlineStatus({ + status, + showLabel = true, + className = '', + size = 'md', + lastSeen, + staleThresholdMinutes = 5, +}: OnlineStatusProps) { + const sizes = sizeClasses[size]; + const config = getStatusConfig(status, lastSeen, staleThresholdMinutes); + const lastSeenText = formatLastSeen(lastSeen); + + const title = lastSeen + ? `${config.label} - Last seen: ${lastSeenText}` + : config.label; + + return ( + + {/* Status dot with optional pulse animation */} + + {/* Outer ring for online status */} + {config.pulse && ( + + )} + {/* Inner dot */} + + + {showLabel && ( + + {config.label} + + )} + + ); +} + +/** + * Compact version that only shows the dot indicator + */ +export function OnlineStatusDot({ + status, + className = '', + size = 'md', + lastSeen, + staleThresholdMinutes = 5, +}: Omit) { + return ( + + ); +} + +/** + * Extended status indicator with last seen time + */ +export function OnlineStatusWithTime({ + status, + className = '', + size = 'md', + lastSeen, + staleThresholdMinutes = 5, +}: Omit) { + const sizes = sizeClasses[size]; + const lastSeenText = formatLastSeen(lastSeen); + + return ( +
    + + {lastSeenText && ( + + {lastSeenText} + + )} +
    + ); +} + +/** + * Large status badge with icon + */ +export function OnlineStatusBadge({ + status, + className = '', + lastSeen, + staleThresholdMinutes = 5, +}: Omit) { + const config = getStatusConfig(status, lastSeen, staleThresholdMinutes); + const lastSeenText = formatLastSeen(lastSeen); + + return ( +
    + {/* Status icon */} +
    + {config.pulse && ( + + )} +
    +
    + + {/* Status text */} +
    + + {config.label} + + {lastSeenText && ( + + Last seen {lastSeenText} + + )} +
    +
    + ); +} diff --git a/frontend/src/components/common/ScriptEditor.tsx b/frontend/src/components/common/ScriptEditor.tsx new file mode 100644 index 0000000..54db13a --- /dev/null +++ b/frontend/src/components/common/ScriptEditor.tsx @@ -0,0 +1,557 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { EditorView, keymap, placeholder } from '@codemirror/view'; +import { EditorState, Compartment } from '@codemirror/state'; +import { javascript } from '@codemirror/lang-javascript'; +import { + autocompletion, + type CompletionContext, + type Completion, +} from '@codemirror/autocomplete'; +import { + defaultKeymap, + history, + historyKeymap, +} from '@codemirror/commands'; +import { + syntaxHighlighting, + HighlightStyle, + bracketMatching, +} from '@codemirror/language'; +import { tags } from '@lezer/highlight'; +import { lineNumbers, highlightActiveLineGutter, highlightActiveLine } from '@codemirror/view'; +import type { FieldMeta } from '../../types'; + +// Custom syntax highlighting with readable colors +const customHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: '#c678dd', fontWeight: 'bold' }, // purple - var, let, const, return + { tag: tags.operator, color: '#56b6c2' }, // cyan - =, +, -, *, / + { tag: tags.variableName, color: '#e06c75' }, // red - variable names + { tag: tags.propertyName, color: '#e5c07b' }, // yellow - property access + { tag: tags.string, color: '#98c379' }, // green - strings + { tag: tags.number, color: '#d19a66' }, // orange - numbers + { tag: tags.bool, color: '#d19a66' }, // orange - true/false + { tag: tags.null, color: '#d19a66' }, // orange - null + { tag: tags.comment, color: '#7f848e', fontStyle: 'italic' }, // gray - comments + { tag: tags.function(tags.variableName), color: '#61afef' }, // blue - function names + { tag: tags.definition(tags.variableName), color: '#e06c75' }, // red - variable definitions + { tag: tags.punctuation, color: '#abb2bf' }, // light gray - brackets, commas + { tag: tags.bracket, color: '#abb2bf' }, // light gray - [], {} +]); + +export interface ScriptExecutionResult { + success: boolean; + output?: unknown; + variables?: Record; + error?: string; +} + +interface ScriptEditorProps { + value: string; + onChange: (value: string) => void; + availableFields: FieldMeta[]; + sampleData: unknown; + outputMode: 'value' | 'template'; + template?: string; + prefix?: string; + suffix?: string; + placeholder?: string; + onExecutionResult?: (result: ScriptExecutionResult | null) => void; +} + +// Safe globals for script execution (mirrors backend) +const SAFE_GLOBALS = { + Math, + String, + Number, + Boolean, + Array: { + isArray: Array.isArray, + from: Array.from, + }, + Object: { + keys: Object.keys, + values: Object.values, + entries: Object.entries, + }, + JSON: { + parse: JSON.parse, + stringify: JSON.stringify, + }, + Date, + parseInt, + parseFloat, + isNaN, + isFinite, + encodeURIComponent, + decodeURIComponent, +}; + +export function ScriptEditor({ + value, + onChange, + availableFields, + sampleData, + outputMode, + template, + prefix = '', + suffix = '', + placeholder: placeholderText, + onExecutionResult, +}: ScriptEditorProps) { + const editorRef = useRef(null); + const viewRef = useRef(null); + const completionCompartment = useRef(new Compartment()); + const [executionResult, setExecutionResult] = useState(null); + const [isExecuting, setIsExecuting] = useState(false); + + // Build completions from available fields + const buildCompletions = useCallback((): Completion[] => { + const completions: Completion[] = []; + + // Add field completions ($.fieldName) + availableFields.forEach((field) => { + const sampleValue = field.sample !== undefined + ? (typeof field.sample === 'string' + ? `"${field.sample.substring(0, 30)}${field.sample.length > 30 ? '...' : ''}"` + : String(field.sample)) + : field.type; + + completions.push({ + label: `$.${field.path}`, + type: 'variable', + info: `${field.type}${field.isImageUrl ? ' (image URL)' : ''}${field.isLink ? ' (link)' : ''}`, + detail: sampleValue, + boost: 1, + }); + }); + + // Add common JavaScript helpers + const helpers: { label: string; type: string; info: string }[] = [ + { label: 'Math.round', type: 'function', info: 'Round to nearest integer' }, + { label: 'Math.floor', type: 'function', info: 'Round down' }, + { label: 'Math.ceil', type: 'function', info: 'Round up' }, + { label: 'Math.abs', type: 'function', info: 'Absolute value' }, + { label: 'Math.min', type: 'function', info: 'Minimum of values' }, + { label: 'Math.max', type: 'function', info: 'Maximum of values' }, + { label: 'Number.toFixed', type: 'method', info: 'Format decimal places' }, + { label: 'String.toUpperCase', type: 'method', info: 'Convert to uppercase' }, + { label: 'String.toLowerCase', type: 'method', info: 'Convert to lowercase' }, + { label: 'String.trim', type: 'method', info: 'Remove whitespace' }, + { label: 'String.substring', type: 'method', info: 'Extract substring' }, + { label: 'JSON.stringify', type: 'function', info: 'Convert to JSON string' }, + { label: 'JSON.parse', type: 'function', info: 'Parse JSON string' }, + { label: 'parseInt', type: 'function', info: 'Parse integer' }, + { label: 'parseFloat', type: 'function', info: 'Parse float' }, + { label: 'isNaN', type: 'function', info: 'Check if NaN' }, + { label: 'new Date', type: 'constructor', info: 'Create date object' }, + { label: 'Date.now', type: 'function', info: 'Current timestamp' }, + ]; + + helpers.forEach((h) => { + completions.push({ + label: h.label, + type: h.type, + info: h.info, + boost: 0, + }); + }); + + return completions; + }, [availableFields]); + + // Custom completion source + const completionSource = useCallback((context: CompletionContext) => { + const word = context.matchBefore(/[\w$.[\]]+/); + if (!word) return null; + + const completions = buildCompletions(); + + // Filter completions based on current word + const filtered = completions.filter((c) => + c.label.toLowerCase().includes(word.text.toLowerCase()) + ); + + return { + from: word.from, + options: filtered, + validFor: /^[\w$.[\]]*$/, + }; + }, [buildCompletions]); + + // Execute JavaScript code safely in browser + const executeScript = useCallback((code: string, data: unknown): ScriptExecutionResult => { + if (!code.trim()) { + return { success: false, error: 'No code to execute' }; + } + + try { + // Create $ proxy object for data access + const createDataProxy = (obj: unknown): unknown => { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + + return new Proxy(obj as object, { + get(target, prop) { + if (typeof prop === 'string') { + const value = (target as Record)[prop]; + if (typeof value === 'object' && value !== null) { + return createDataProxy(value); + } + return value; + } + return undefined; + }, + }); + }; + + const $ = createDataProxy(data); + + // Build function body + let functionBody: string; + + if (outputMode === 'template') { + // Template mode: collect variables + functionBody = ` + ${code} + return { + ${code.match(/(?:var|let|const)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g) + ?.map(match => match.replace(/(?:var|let|const)\s+/, '')) + .filter(v => !v.startsWith('__')) + .join(', ') || ''} + }; + `; + } else { + // Value mode: expect return statement + functionBody = code; + } + + // Create sandboxed function + const fn = new Function( + '$', + 'Math', + 'String', + 'Number', + 'Boolean', + 'Array', + 'Object', + 'JSON', + 'Date', + 'parseInt', + 'parseFloat', + 'isNaN', + 'isFinite', + 'encodeURIComponent', + 'decodeURIComponent', + functionBody + ); + + // Execute with timeout protection + const startTime = Date.now(); + const result = fn( + $, + SAFE_GLOBALS.Math, + SAFE_GLOBALS.String, + SAFE_GLOBALS.Number, + SAFE_GLOBALS.Boolean, + SAFE_GLOBALS.Array, + SAFE_GLOBALS.Object, + SAFE_GLOBALS.JSON, + SAFE_GLOBALS.Date, + SAFE_GLOBALS.parseInt, + SAFE_GLOBALS.parseFloat, + SAFE_GLOBALS.isNaN, + SAFE_GLOBALS.isFinite, + SAFE_GLOBALS.encodeURIComponent, + SAFE_GLOBALS.decodeURIComponent + ); + const duration = Date.now() - startTime; + + if (duration > 1000) { + return { success: false, error: 'Script took too long to execute' }; + } + + if (outputMode === 'template') { + return { success: true, variables: result as Record }; + } else { + return { success: true, output: result }; + } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }, [outputMode]); + + // Run script when code or data changes + useEffect(() => { + if (!value || !sampleData) { + setExecutionResult(null); + onExecutionResult?.(null); + return; + } + + setIsExecuting(true); + // Small delay to avoid running on every keystroke + const timer = setTimeout(() => { + const result = executeScript(value, sampleData); + setExecutionResult(result); + onExecutionResult?.(result); + setIsExecuting(false); + }, 300); + + return () => clearTimeout(timer); + }, [value, sampleData, executeScript, onExecutionResult]); + + // Initialize CodeMirror + useEffect(() => { + if (!editorRef.current || viewRef.current) return; + + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + history(), + bracketMatching(), + syntaxHighlighting(customHighlightStyle), + javascript(), + keymap.of([...defaultKeymap, ...historyKeymap]), + completionCompartment.current.of( + autocompletion({ + override: [completionSource], + activateOnTyping: true, + icons: true, + }) + ), + placeholder(placeholderText || ''), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChange(update.state.doc.toString()); + } + }), + // Custom dark theme with readable colors + EditorView.theme({ + '&': { + height: '220px', + fontSize: '14px', + backgroundColor: '#1e1e2e', + color: '#cdd6f4', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + }, + '.cm-content': { + padding: '12px 0', + caretColor: '#f5e0dc', + }, + '.cm-cursor': { + borderLeftColor: '#f5e0dc', + borderLeftWidth: '2px', + }, + '.cm-gutters': { + backgroundColor: '#181825', + color: '#6c7086', + border: 'none', + borderRight: '1px solid #313244', + }, + '.cm-activeLineGutter': { + backgroundColor: '#313244', + color: '#cdd6f4', + }, + '.cm-activeLine': { + backgroundColor: '#313244', + }, + '.cm-selectionBackground': { + backgroundColor: '#45475a !important', + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: '#45475a !important', + }, + '.cm-matchingBracket': { + backgroundColor: '#585b70', + color: '#f5e0dc', + outline: '1px solid #f5e0dc', + }, + '.cm-placeholder': { + color: '#6c7086', + fontStyle: 'italic', + }, + // Autocomplete tooltip styling + '.cm-tooltip': { + backgroundColor: '#1e1e2e', + border: '1px solid #45475a', + borderRadius: '8px', + boxShadow: '0 4px 12px rgba(0,0,0,0.4)', + }, + '.cm-tooltip.cm-tooltip-autocomplete': { + backgroundColor: '#1e1e2e', + }, + '.cm-tooltip-autocomplete ul': { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + }, + '.cm-tooltip-autocomplete ul li': { + padding: '6px 12px', + color: '#cdd6f4', + }, + '.cm-tooltip-autocomplete ul li[aria-selected]': { + backgroundColor: '#45475a', + color: '#f5e0dc', + }, + '.cm-completionLabel': { + color: '#89b4fa', + }, + '.cm-completionIcon': { + marginRight: '8px', + color: '#cba6f7', + }, + '.cm-completionDetail': { + marginLeft: '12px', + color: '#a6adc8', + fontStyle: 'italic', + }, + '.cm-completionInfo': { + padding: '8px 12px', + backgroundColor: '#181825', + borderLeft: '1px solid #45475a', + color: '#bac2de', + }, + }, { dark: true }), + ], + }); + + viewRef.current = new EditorView({ + state, + parent: editorRef.current, + }); + + return () => { + viewRef.current?.destroy(); + viewRef.current = null; + }; + // Only run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update completion source when fields change + useEffect(() => { + if (viewRef.current) { + viewRef.current.dispatch({ + effects: completionCompartment.current.reconfigure( + autocompletion({ + override: [completionSource], + activateOnTyping: true, + icons: true, + }) + ), + }); + } + }, [completionSource]); + + // Update editor content when value changes externally + useEffect(() => { + if (viewRef.current) { + const currentValue = viewRef.current.state.doc.toString(); + if (currentValue !== value) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: currentValue.length, + insert: value, + }, + }); + } + } + }, [value]); + + // Render final output with template substitution + const renderOutput = () => { + if (!executionResult?.success) return null; + + if (outputMode === 'template' && executionResult.variables) { + let output = template || ''; + Object.entries(executionResult.variables).forEach(([key, val]) => { + output = output.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val)); + }); + return output; + } else if (outputMode === 'value' && executionResult.output !== undefined) { + return `${prefix}${String(executionResult.output)}${suffix}`; + } + return null; + }; + + return ( +
    + {/* Editor */} +
    +
    +
    + + {/* Hint about $ access */} +

    + Type $. to see available fields from API. + Press Ctrl+Space for autocomplete. +

    + + {/* Live Execution Result */} +
    +
    + Live Output + {isExecuting && ( + + + + + )} +
    +
    + {!sampleData ? ( + Select a data source to see live output + ) : !value ? ( + Write JavaScript code to see output + ) : executionResult?.success ? ( +
    + {/* Final rendered output */} +
    +
    Result:
    +
    + {renderOutput()} +
    +
    + + {/* Variables in template mode */} + {outputMode === 'template' && executionResult.variables && ( +
    +
    Variables:
    +
    + {Object.entries(executionResult.variables).map(([key, val]) => ( +
    + {key} + = + {String(val)} +
    + ))} +
    +
    + )} + + {/* Raw return value in value mode */} + {outputMode === 'value' && ( +
    + Raw: {JSON.stringify(executionResult.output)} +
    + )} +
    + ) : executionResult?.error ? ( +
    +
    Error:
    +
    {executionResult.error}
    +
    + ) : null} +
    +
    +
    + ); +} diff --git a/frontend/src/components/common/SearchableSelect.tsx b/frontend/src/components/common/SearchableSelect.tsx new file mode 100644 index 0000000..26e474a --- /dev/null +++ b/frontend/src/components/common/SearchableSelect.tsx @@ -0,0 +1,277 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; + +interface Option { + value: string; + label: string; + /** Optional hint shown next to label (e.g., matched city name) */ + hint?: string; + /** Optional group header - if set, this is a non-selectable group divider */ + isGroup?: boolean; +} + +interface SearchableSelectProps { + value: string; + onChange: (value: string) => void; + options: Option[]; + placeholder?: string; + className?: string; + /** Custom search function - receives query, returns filtered/sorted options */ + searchFn?: (query: string) => Option[]; +} + +/** + * Searchable select component with autocomplete + * Type to filter options, click or press Enter to select + * Supports custom search function for advanced matching (e.g., city-to-timezone) + * Uses Portal to render dropdown above all other elements + */ +export function SearchableSelect({ + value, + onChange, + options, + placeholder = 'Search...', + className = '', + searchFn, +}: SearchableSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const inputRef = useRef(null); + const listRef = useRef(null); + const containerRef = useRef(null); + + // Get display label for current value + const selectedOption = options.find(opt => opt.value === value); + const displayValue = selectedOption?.label || value || ''; + + // Filter options based on search term - use custom searchFn if provided + const filteredOptions = useMemo(() => { + if (searchFn) { + return searchFn(searchTerm); + } + if (!searchTerm) return options; + return options.filter(opt => + opt.label.toLowerCase().includes(searchTerm.toLowerCase()) || + opt.value.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [searchTerm, options, searchFn]); + + // Calculate dropdown position when opening (fixed position = viewport coordinates) + const updateDropdownPosition = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + 4, // 4px gap below input + left: rect.left, + width: rect.width, + }); + } + }, []); + + // Update position when opening and on scroll/resize + useEffect(() => { + if (isOpen) { + updateDropdownPosition(); + window.addEventListener('scroll', updateDropdownPosition, true); + window.addEventListener('resize', updateDropdownPosition); + return () => { + window.removeEventListener('scroll', updateDropdownPosition, true); + window.removeEventListener('resize', updateDropdownPosition); + }; + } + }, [isOpen, updateDropdownPosition]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if ( + containerRef.current && + !containerRef.current.contains(target) && + listRef.current && + !listRef.current.contains(target) + ) { + setIsOpen(false); + setSearchTerm(''); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Scroll highlighted option into view + useEffect(() => { + if (isOpen && listRef.current) { + const highlightedEl = listRef.current.children[highlightedIndex] as HTMLElement; + if (highlightedEl) { + highlightedEl.scrollIntoView({ block: 'nearest' }); + } + } + }, [highlightedIndex, isOpen]); + + + const handleSelect = useCallback((optionValue: string) => { + onChange(optionValue); + setIsOpen(false); + setSearchTerm(''); + inputRef.current?.blur(); + }, [onChange]); + + // Find next selectable index (skip group headers) + const findNextSelectableIndex = useCallback((currentIndex: number, direction: 'up' | 'down'): number => { + const step = direction === 'down' ? 1 : -1; + let nextIndex = currentIndex + step; + + while (nextIndex >= 0 && nextIndex < filteredOptions.length) { + if (!filteredOptions[nextIndex].isGroup) { + return nextIndex; + } + nextIndex += step; + } + return currentIndex; // Stay at current if no valid option found + }, [filteredOptions]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === ' ') { + e.preventDefault(); + setIsOpen(true); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex(prev => findNextSelectableIndex(prev, 'down')); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex(prev => findNextSelectableIndex(prev, 'up')); + break; + case 'Enter': + e.preventDefault(); + if (filteredOptions[highlightedIndex] && !filteredOptions[highlightedIndex].isGroup) { + handleSelect(filteredOptions[highlightedIndex].value); + } + break; + case 'Escape': + e.preventDefault(); + setIsOpen(false); + setSearchTerm(''); + break; + case 'Tab': + setIsOpen(false); + setSearchTerm(''); + break; + } + }; + + // Render dropdown in a portal to escape overflow containers + // Use explicit white background as fallback for CSS variable + const dropdownContent = isOpen && createPortal( +
      + {filteredOptions.length === 0 ? ( +
    • + No matches found +
    • + ) : ( + filteredOptions.map((option, index) => ( + option.isGroup ? ( + // Group header - non-selectable, solid background +
    • + {option.label} +
    • + ) : ( +
    • handleSelect(option.value)} + onMouseEnter={() => setHighlightedIndex(index)} + > + + {option.label} + {option.hint && ( + + ← {option.hint} + + )} + {option.value === value && ( + + )} + +
    • + ) + )) + )} +
    , + document.body + ); + + return ( +
    +
    { + setIsOpen(true); + inputRef.current?.focus(); + }} + > + { + setSearchTerm(e.target.value); + setHighlightedIndex(0); + if (!isOpen) setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={isOpen ? placeholder : displayValue || placeholder} + className="flex-1 bg-transparent outline-none text-sm min-w-0" + /> + {/* Dropdown arrow */} + + + +
    + + {dropdownContent} +
    + ); +} diff --git a/frontend/src/components/common/Select.tsx b/frontend/src/components/common/Select.tsx new file mode 100644 index 0000000..6247a44 --- /dev/null +++ b/frontend/src/components/common/Select.tsx @@ -0,0 +1,75 @@ +import { type SelectHTMLAttributes, forwardRef, type ReactNode } from 'react'; + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + fullWidth?: boolean; + children: ReactNode; +} + +/** + * Reusable Select component with monochrome theme + * Uses CSS variables for easy theme customization + */ +export const Select = forwardRef( + ( + { + label, + error, + helperText, + fullWidth = true, + className = '', + id, + children, + ...props + }, + ref + ) => { + const selectId = id || `select-${label?.toLowerCase().replace(/\s+/g, '-')}`; + const widthClass = fullWidth ? 'w-full' : ''; + + const selectClasses = ` + block px-4 py-2.5 border-2 rounded-xl + bg-bg-input text-text-primary + transition-all duration-200 ease-in-out + focus:outline-none focus:ring-0 + disabled:bg-bg-muted disabled:text-text-muted disabled:cursor-not-allowed + ${error + ? 'border-status-error-border focus:border-status-error-text' + : 'border-border-light focus:border-accent hover:border-border-default' + } + ${widthClass} + ${className} + `.replace(/\s+/g, ' ').trim(); + + return ( +
    + {label && ( + + )} + + {error && ( +

    {error}

    + )} + {helperText && !error && ( +

    {helperText}

    + )} +
    + ); + } +); + +Select.displayName = 'Select'; diff --git a/frontend/src/components/common/StatusBadge.tsx b/frontend/src/components/common/StatusBadge.tsx new file mode 100644 index 0000000..68034b1 --- /dev/null +++ b/frontend/src/components/common/StatusBadge.tsx @@ -0,0 +1,145 @@ +interface StatusBadgeProps { + status: string; + className?: string; + size?: 'sm' | 'md' | 'lg'; + variant?: 'solid' | 'soft' | 'outline'; +} + +type StatusConfig = { + solid: { + bg: string; + text: string; + }; + soft: { + bg: string; + text: string; + border: string; + }; + outline: { + border: string; + text: string; + }; + dot: string; + label: string; +}; + +/** + * Status badge component with monochrome theme + * Uses CSS variables for easy theme customization + */ +export function StatusBadge({ + status, + className = '', + size = 'md', + variant = 'soft', +}: StatusBadgeProps) { + const statusConfig: Record = { + online: { + solid: { bg: 'bg-status-success-text', text: 'text-white' }, + soft: { bg: 'bg-status-success-bg', text: 'text-status-success-text', border: 'border-status-success-border' }, + outline: { border: 'border-status-success-border', text: 'text-status-success-text' }, + dot: 'bg-status-success-dot', + label: 'Online', + }, + offline: { + soft: { bg: 'bg-bg-muted', text: 'text-text-secondary', border: 'border-border-light' }, + solid: { bg: 'bg-text-muted', text: 'text-text-inverse' }, + outline: { border: 'border-border-default', text: 'text-text-secondary' }, + dot: 'bg-text-muted', + label: 'Offline', + }, + active: { + solid: { bg: 'bg-accent', text: 'text-white' }, + soft: { bg: 'bg-accent-light', text: 'text-accent-text', border: 'border-accent/30' }, + outline: { border: 'border-accent', text: 'text-accent' }, + dot: 'bg-accent', + label: 'Active', + }, + inactive: { + solid: { bg: 'bg-status-warning-text', text: 'text-white' }, + soft: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border' }, + outline: { border: 'border-status-warning-border', text: 'text-status-warning-text' }, + dot: 'bg-status-warning-dot', + label: 'Inactive', + }, + pending: { + solid: { bg: 'bg-status-info-text', text: 'text-white' }, + soft: { bg: 'bg-status-info-bg', text: 'text-status-info-text', border: 'border-status-info-border' }, + outline: { border: 'border-status-info-border', text: 'text-status-info-text' }, + dot: 'bg-status-info-dot', + label: 'Pending', + }, + error: { + solid: { bg: 'bg-status-error-text', text: 'text-white' }, + soft: { bg: 'bg-status-error-bg', text: 'text-status-error-text', border: 'border-status-error-border' }, + outline: { border: 'border-status-error-border', text: 'text-status-error-text' }, + dot: 'bg-status-error-dot', + label: 'Error', + }, + success: { + solid: { bg: 'bg-status-success-text', text: 'text-white' }, + soft: { bg: 'bg-status-success-bg', text: 'text-status-success-text', border: 'border-status-success-border' }, + outline: { border: 'border-status-success-border', text: 'text-status-success-text' }, + dot: 'bg-status-success-dot', + label: 'Success', + }, + warning: { + solid: { bg: 'bg-status-warning-text', text: 'text-white' }, + soft: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border' }, + outline: { border: 'border-status-warning-border', text: 'text-status-warning-text' }, + dot: 'bg-status-warning-dot', + label: 'Warning', + }, + }; + + // Default fallback for unknown status values + const defaultConfig: StatusConfig = { + solid: { bg: 'bg-text-muted', text: 'text-text-inverse' }, + soft: { bg: 'bg-bg-muted', text: 'text-text-secondary', border: 'border-border-light' }, + outline: { border: 'border-border-default', text: 'text-text-secondary' }, + dot: 'bg-text-muted', + label: status || 'Unknown', + }; + + const sizeClasses = { + sm: 'px-2 py-0.5 text-xs gap-1', + md: 'px-2.5 py-1 text-xs gap-1.5', + lg: 'px-3 py-1.5 text-sm gap-2', + }; + + const dotSizeClasses = { + sm: 'h-1.5 w-1.5', + md: 'h-2 w-2', + lg: 'h-2.5 w-2.5', + }; + + // Safely access config with fallback + const config = statusConfig[status?.toLowerCase()] || defaultConfig; + + const getVariantClasses = () => { + switch (variant) { + case 'solid': + return `${config.solid.bg} ${config.solid.text}`; + case 'outline': + return `bg-transparent border-2 ${config.outline.border} ${config.outline.text}`; + case 'soft': + default: + return `${config.soft.bg} ${config.soft.text} border ${config.soft.border}`; + } + }; + + return ( + + + {config.label} + + ); +} diff --git a/frontend/src/components/common/Toast.tsx b/frontend/src/components/common/Toast.tsx new file mode 100644 index 0000000..d6c6b89 --- /dev/null +++ b/frontend/src/components/common/Toast.tsx @@ -0,0 +1,121 @@ +import type { Notification } from '../../types'; +import { useNotification } from '../../contexts/NotificationContext'; + +interface ToastProps { + notification: Notification; +} + +/** + * Individual toast notification component with monochrome theme + * Uses CSS variables for easy theme customization + */ +function ToastItem({ notification }: ToastProps) { + const { removeNotification } = useNotification(); + + const typeConfig = { + success: { + bg: 'bg-status-success-bg', + border: 'border-status-success-border', + text: 'text-status-success-text', + iconBg: 'bg-status-success-text', + icon: ( + + + + ), + }, + error: { + bg: 'bg-status-error-bg', + border: 'border-status-error-border', + text: 'text-status-error-text', + iconBg: 'bg-status-error-text', + icon: ( + + + + ), + }, + warning: { + bg: 'bg-status-warning-bg', + border: 'border-status-warning-border', + text: 'text-status-warning-text', + iconBg: 'bg-status-warning-text', + icon: ( + + + + ), + }, + info: { + bg: 'bg-status-info-bg', + border: 'border-status-info-border', + text: 'text-status-info-text', + iconBg: 'bg-status-info-text', + icon: ( + + + + ), + }, + }; + + const config = typeConfig[notification.type]; + + return ( +
    + {/* Icon container */} +
    + {config.icon} +
    + + {/* Message */} +
    +

    + {notification.type.charAt(0).toUpperCase() + notification.type.slice(1)} +

    +

    + {notification.message} +

    +
    + + {/* Close button */} + +
    + ); +} + +/** + * Toast container that displays all notifications + */ +export function ToastContainer() { + const { notifications } = useNotification(); + + return ( +
    + {notifications.map((notification) => ( +
    + +
    + ))} +
    + ); +} diff --git a/frontend/src/components/common/WifiIndicator.tsx b/frontend/src/components/common/WifiIndicator.tsx new file mode 100644 index 0000000..da2a0dc --- /dev/null +++ b/frontend/src/components/common/WifiIndicator.tsx @@ -0,0 +1,253 @@ +/** + * WiFi signal strength indicator component + * Uses hardcoded colors that don't change with theme + */ + +// Hardcoded status colors (theme-independent, bright/vivid) +const STATUS_COLORS = { + success: { + bg: '#DCFCE7', + text: '#16A34A', + dot: '#22C55E', + }, + error: { + bg: '#FEE2E2', + text: '#DC2626', + dot: '#EF4444', + }, + warning: { + bg: '#FEF3C7', + text: '#D97706', + dot: '#F59E0B', + }, + info: { + bg: '#DBEAFE', + text: '#2563EB', + dot: '#3B82F6', + }, +}; + +interface WifiIndicatorProps { + /** WiFi signal strength in dBm (typically -30 to -90) */ + signal: number | undefined | null; + /** Whether to show the dBm value */ + showValue?: boolean; + /** Additional CSS classes */ + className?: string; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; +} + +type WifiConfig = { + bars: number; + colors: typeof STATUS_COLORS.success; + label: string; +}; + +/** + * Determines WiFi signal quality based on RSSI (dBm) + */ +function getWifiConfig(signal: number): WifiConfig { + if (signal > -50) { + return { + bars: 4, + colors: STATUS_COLORS.success, + label: 'Excellent', + }; + } + if (signal >= -60) { + return { + bars: 3, + colors: STATUS_COLORS.info, + label: 'Good', + }; + } + if (signal >= -70) { + return { + bars: 2, + colors: STATUS_COLORS.warning, + label: 'Fair', + }; + } + return { + bars: 1, + colors: STATUS_COLORS.error, + label: 'Poor', + }; +} + +const sizeClasses = { + sm: { + container: 'h-4', + text: 'text-xs font-medium', + gap: 'gap-1', + badge: 'px-2 py-0.5', + barGap: 'gap-0.5', + barWidth: 'w-1', + barHeights: ['h-1.5', 'h-2.5', 'h-3.5', 'h-4'], + }, + md: { + container: 'h-5', + text: 'text-sm font-medium', + gap: 'gap-1.5', + badge: 'px-2.5 py-1', + barGap: 'gap-0.5', + barWidth: 'w-1.5', + barHeights: ['h-2', 'h-3', 'h-4', 'h-5'], + }, + lg: { + container: 'h-6', + text: 'text-base font-semibold', + gap: 'gap-2', + badge: 'px-3 py-1.5', + barGap: 'gap-1', + barWidth: 'w-2', + barHeights: ['h-2.5', 'h-3.5', 'h-5', 'h-6'], + }, +}; + +export function WifiIndicator({ + signal, + showValue = false, + className = '', + size = 'md', +}: WifiIndicatorProps) { + const sizes = sizeClasses[size]; + + // Handle null/undefined values gracefully + if (signal === null || signal === undefined) { + return ( + +
    + + {showValue && N/A} +
    +
    + ); + } + + const config = getWifiConfig(signal); + + return ( + +
    + + {showValue && ( + {signal} dBm + )} +
    +
    + ); +} + +interface WifiIconProps { + size: 'sm' | 'md' | 'lg'; + activeBars: number; + barActiveColor: string; +} + +function WifiIcon({ size, activeBars, barActiveColor }: WifiIconProps) { + const sizes = sizeClasses[size]; + + return ( +
    + {[1, 2, 3, 4].map((barNumber) => { + const isActive = barNumber <= activeBars; + const barHeight = sizes.barHeights[barNumber - 1]; + + return ( +
    + ); + })} +
    + ); +} + +/** + * WiFi indicator with signal strength label + */ +export function WifiIndicatorLabeled({ + signal, + className = '', + size = 'md', +}: Omit) { + const sizes = sizeClasses[size]; + + if (signal === null || signal === undefined) { + return ( + +
    + + No Signal +
    +
    + ); + } + + const config = getWifiConfig(signal); + + return ( + +
    + + {config.label} +
    +
    + ); +} + +/** + * Compact WiFi indicator showing just the bars + */ +export function WifiIndicatorCompact({ + signal, + className = '', + size = 'sm', +}: Omit) { + return ( + + ); +} diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts new file mode 100644 index 0000000..86c270e --- /dev/null +++ b/frontend/src/components/common/index.ts @@ -0,0 +1,30 @@ +// Export all common components +export { Button } from './Button'; +export { Input } from './Input'; +export { Select } from './Select'; +export { SearchableSelect } from './SearchableSelect'; +export { Modal } from './Modal'; +export { LoadingSpinner, LoadingOverlay, LoadingDots } from './LoadingSpinner'; +export { StatusBadge } from './StatusBadge'; +export { Card, CardHeader, CardBody, CardFooter } from './Card'; +export { ToastContainer } from './Toast'; +export { ErrorBoundary } from './ErrorBoundary'; +export { + Skeleton, + CardSkeleton, + TableRowSkeleton, + ListItemSkeleton, + GridSkeleton, + FormSkeleton, +} from './LoadingSkeleton'; + +// Device status indicators +export { BatteryIndicator } from './BatteryIndicator'; +export { WifiIndicator } from './WifiIndicator'; +export { OnlineStatus, OnlineStatusDot } from './OnlineStatus'; + +// Image components +export { EInkImage } from './EInkImage'; + +// Widget preview +export { CustomWidgetPreviewRenderer } from './CustomWidgetPreviewRenderer'; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..dd8a679 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,94 @@ +import { useAuth } from '../../contexts/AuthContext'; +import { Button } from '../common'; + +/** + * Header component with user menu + * Uses CSS variable-based theming for easy customization + */ +export function Header() { + const { logout, isAuthenticated } = useAuth(); + + const handleLogout = () => { + logout(); + }; + + // Get current date for display + const currentDate = new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ( +
    +
    +
    + {/* Left side - Page context / Breadcrumb area */} +
    + + {currentDate} + +

    + Welcome back +

    +
    + + {/* Right side - User actions */} + {isAuthenticated && ( +
    + {/* User profile section */} +
    + {/* User avatar */} +
    +
    + + AD + +
    + {/* Online indicator */} + +
    + + {/* User info */} +
    +

    + Administrator +

    +
    + + Admin + +
    +
    + + {/* Logout button */} + +
    +
    + )} +
    +
    +
    + ); +} diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..e4d6049 --- /dev/null +++ b/frontend/src/components/layout/MainLayout.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react'; +import { Header } from './Header'; +import { Sidebar } from './Sidebar'; + +interface MainLayoutProps { + children: ReactNode; +} + +/** + * Main layout component with header, sidebar, and content area + * Uses CSS variable-based theming for easy customization + * Note: ToastContainer is now global in App.tsx + */ +export function MainLayout({ children }: MainLayoutProps) { + return ( +
    + {/* Sidebar - Fixed position on the left */} + + + {/* Main content area with left margin for sidebar */} +
    +
    +
    +
    + {children} +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..064cb69 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,221 @@ +import { Link, useLocation } from 'react-router-dom'; +import { version } from '../../../package.json'; + +interface NavItem { + to: string; + label: string; + icon: React.ReactNode; +} + +/** + * Sidebar navigation component with monochrome theme and subtle accent + * Uses CSS variables for easy theme customization + */ +export function Sidebar() { + const location = useLocation(); + + const navItems: NavItem[] = [ + { + to: '/dashboard', + label: 'Dashboard', + icon: ( + + + + ), + }, + { + to: '/devices', + label: 'Devices', + icon: ( + + + + ), + }, + { + to: '/screens', + label: 'Screens', + icon: ( + + + + ), + }, + { + to: '/playlists', + label: 'Playlists', + icon: ( + + + + ), + }, + { + to: '/extensions', + label: 'Extensions', + icon: ( + + + + ), + }, + { + to: '/settings', + label: 'Settings', + icon: ( + + + + + ), + }, + ]; + + const isActive = (path: string) => location.pathname.startsWith(path); + + return ( + + ); +} diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts new file mode 100644 index 0000000..64f54c7 --- /dev/null +++ b/frontend/src/components/layout/index.ts @@ -0,0 +1,4 @@ +// Export layout components +export { Header } from './Header'; +export { Sidebar } from './Sidebar'; +export { MainLayout } from './MainLayout'; diff --git a/frontend/src/components/screen-designer/DesignCanvas.tsx b/frontend/src/components/screen-designer/DesignCanvas.tsx new file mode 100644 index 0000000..3e51a8c --- /dev/null +++ b/frontend/src/components/screen-designer/DesignCanvas.tsx @@ -0,0 +1,859 @@ +/** + * DesignCanvas Component + * + * Simple 800×480 canvas for placing widgets. + * Anti-aliasing disabled for pixel-perfect e-ink output. + * What you see = what device gets. + */ +import { useRef, useCallback, useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import type { ScreenWidget, WidgetTemplate } from '../../types'; +import { DraggableWidget, type ActiveGuides } from './DraggableWidget'; +import { screenDesignerService } from '../../services/api'; +import config from '../../config'; + +export interface DesignCanvasHandle { + captureForDevice: () => Promise<{ captureUrl: string; filename: string; size: number } | null>; + hasDrawingContent: () => boolean; + saveDrawingAsWidget: () => Promise; + clearDrawing: (deleteFromServer?: boolean) => void; +} + +interface DesignCanvasProps { + screenId?: number; + width: number; + height: number; + background: string; + widgets: ScreenWidget[]; + templates: WidgetTemplate[]; + selectedWidgetId: number | null; + onSelectWidget: (id: number | null) => void; + onUpdateWidget: (id: number, updates: Partial) => void; + onDeleteWidget: (id: number) => void; + onDropWidget: (template: WidgetTemplate, x: number, y: number) => void; +} + +export const DesignCanvas = forwardRef(function DesignCanvas({ + screenId, + width, + height, + background, + widgets, + templates, + selectedWidgetId, + onSelectWidget, + onUpdateWidget, + onDeleteWidget, + onDropWidget, +}, ref) { + const canvasRef = useRef(null); + const widgetsContainerRef = useRef(null); + + // UI state + const [isDragOver, setIsDragOver] = useState(false); + const [showGrid, setShowGrid] = useState(true); + + // Track dragging state and active snap guides + const [isWidgetDragging, setIsWidgetDragging] = useState(false); + const [activeGuides, setActiveGuides] = useState({ + vertical: [], + horizontal: [], + widgetVertical: [], + widgetHorizontal: [], + }); + + // Drawing mode state + const [isDrawingMode, setIsDrawingMode] = useState(false); + const [isDrawing, setIsDrawing] = useState(false); + const [brushSize, setBrushSize] = useState(3); + const [brushColor, setBrushColor] = useState('#000000'); + const [isEraser, setIsEraser] = useState(false); + const [isBucket, setIsBucket] = useState(false); + const [hasDrawing, setHasDrawing] = useState(false); + const drawingCanvasRef = useRef(null); + const lastPointRef = useRef<{ x: number; y: number } | null>(null); + + // Undo/Redo history (capped to limit memory — each entry is ~1.5 MB) + const MAX_HISTORY = 50; + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + /** + * Load existing drawing when screen is opened + */ + useEffect(() => { + if (!screenId) return; + + let img: HTMLImageElement | null = null; + + const loadDrawing = async () => { + try { + const drawingInfo = await screenDesignerService.getDrawing(screenId); + if (drawingInfo.exists && drawingInfo.url) { + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Load the drawing image + img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + // Clear canvas first + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Draw the loaded image + ctx.drawImage(img!, 0, 0, canvas.width, canvas.height); + setHasDrawing(true); + }; + img.onerror = (err) => { + console.warn(`[DesignCanvas] Failed to load drawing:`, err); + }; + // Add cache buster to avoid stale images + img.src = `${config.backendUrl}${drawingInfo.url}?t=${Date.now()}`; + } + } catch (error) { + console.warn('[DesignCanvas] Error loading drawing:', error); + } + }; + + loadDrawing(); + + return () => { + if (img) { + img.onload = null; + img.onerror = null; + img.src = ''; + } + }; + }, [screenId]); + + /** + * Check if drawing canvas has any content + */ + const hasDrawingContent = useCallback((): boolean => { + const canvas = drawingCanvasRef.current; + if (!canvas) return false; + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + return imageData.data.some((val, i) => i % 4 === 3 && val > 0); + }, []); + + /** + * Capture for device - sends exact pixels to backend. + * Includes any freehand drawings on the canvas. + * + * Strategy: Send drawing as separate PNG to backend, let backend render + * widgets with Puppeteer and composite them together. This avoids browser + * canvas taint restrictions. + */ + const captureForDevice = useCallback(async (): Promise<{ captureUrl: string; filename: string; size: number } | null> => { + if (!screenId) { + console.warn('Cannot capture: missing screenId'); + return null; + } + + const drawingCanvas = drawingCanvasRef.current; + const hasDrawingData = hasDrawingContent(); + + try { + // Capture drawing as PNG blob (if there's drawing content) + let drawingBlob: Blob | null = null; + if (hasDrawingData && drawingCanvas) { + drawingBlob = await new Promise((resolve, reject) => { + drawingCanvas.toBlob((b) => { + if (b) resolve(b); + else reject(new Error('Failed to convert drawing to blob')); + }, 'image/png'); + }); + } + + // Send to backend - backend will render widgets with Puppeteer and composite drawing + const result = await screenDesignerService.captureWithDrawing(screenId, drawingBlob); + return result; + } catch (error) { + console.error('[captureForDevice] Failed:', error); + return null; + } + }, [screenId, hasDrawingContent]); + + const handleWidgetDragStateChange = useCallback((isDragging: boolean, guides: ActiveGuides) => { + setIsWidgetDragging(isDragging); + setActiveGuides(guides); + }, []); + + const handleCanvasClick = useCallback(() => { + onSelectWidget(null); + }, [onSelectWidget]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + const data = e.dataTransfer.getData('application/json'); + if (!data) return; + + try { + const template = JSON.parse(data) as WidgetTemplate; + if (canvasRef.current) { + const canvasRect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - canvasRect.left; + const y = e.clientY - canvasRect.top; + const dropX = Math.max(0, Math.min(width - template.minWidth, x - template.minWidth / 2)); + const dropY = Math.max(0, Math.min(height - template.minHeight, y - template.minHeight / 2)); + onDropWidget(template, Math.round(dropX), Math.round(dropY)); + } + } catch (err) { + console.error('Failed to parse dropped widget data:', err); + } + }, [width, height, onDropWidget]); + + const getTemplate = useCallback((templateId: number): WidgetTemplate | undefined => { + return templates.find((t) => t.id === templateId); + }, [templates]); + + // Drawing mode handlers + const handleToggleDrawingMode = useCallback(() => { + // Warn if leaving drawing mode with unsaved content + if (isDrawingMode && hasDrawingContent()) { + const confirmLeave = window.confirm( + 'You have unsaved drawing content. The drawing will be included when you save the screen.\n\nClick "Save" in the toolbar to convert it to an image widget first, or click OK to keep as overlay.' + ); + if (!confirmLeave) return; + } + setIsDrawingMode((prev) => !prev); + if (!isDrawingMode) onSelectWidget(null); + }, [isDrawingMode, onSelectWidget, hasDrawingContent]); + + const handleClearDrawing = useCallback(async (deleteFromServer = false) => { + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + setHasDrawing(false); + + // Optionally delete from server + if (deleteFromServer && screenId) { + try { + await screenDesignerService.deleteDrawing(screenId); + } catch (error) { + console.warn('[DesignCanvas] Failed to delete drawing from server:', error); + } + } + }, [screenId]); + + const handleSaveDrawing = useCallback(async () => { + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const hasContent = imageData.data.some((val, i) => i % 4 === 3 && val > 0); + if (!hasContent) { + alert('Nothing to save - draw something first'); + return; + } + + const imageTemplate = templates.find(t => t.name === 'image'); + if (!imageTemplate) { + alert('Error: Image widget template not found'); + return; + } + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) { + alert('Error: Failed to save drawing'); + return; + } + + try { + const file = new File([blob], 'drawing.png', { type: 'image/png' }); + const result = await screenDesignerService.uploadWidgetImage(file); + const dropX = Math.max(0, (width - 200) / 2); + const dropY = Math.max(0, (height - 150) / 2); + onDropWidget({ + ...imageTemplate, + defaultConfig: { ...imageTemplate.defaultConfig, imageUrl: result.url }, + }, dropX, dropY); + handleClearDrawing(); + setIsDrawingMode(false); + } catch (error) { + console.error('Failed to save drawing:', error); + alert('Failed to save drawing. Please try again.'); + } + }, [templates, width, height, onDropWidget, handleClearDrawing]); + + // Expose methods to parent component + useImperativeHandle(ref, () => ({ + captureForDevice, + hasDrawingContent, + saveDrawingAsWidget: handleSaveDrawing, + clearDrawing: handleClearDrawing, + }), [captureForDevice, hasDrawingContent, handleSaveDrawing, handleClearDrawing]); + + const getCanvasPoint = useCallback((e: MouseEvent | React.MouseEvent): { x: number; y: number } => { + const canvas = drawingCanvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + // Scale from displayed coordinates to canvas internal coordinates + // This handles cases where CSS display size differs from canvas resolution + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY + }; + }, []); + + // Flood fill (bucket) function - defined before handleDrawStart since it's used there + const handleBucketFill = useCallback((e: React.MouseEvent) => { + if (!isDrawingMode || !isBucket) return; + e.preventDefault(); + e.stopPropagation(); + + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const point = getCanvasPoint(e); + const x = Math.floor(point.x); + const y = Math.floor(point.y); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + + // Get target color at click position + const targetIdx = (y * canvasWidth + x) * 4; + const targetR = data[targetIdx]; + const targetG = data[targetIdx + 1]; + const targetB = data[targetIdx + 2]; + const targetA = data[targetIdx + 3]; + + // Parse fill color + const fillColor = brushColor === '#000000' + ? { r: 0, g: 0, b: 0, a: 255 } + : { r: 255, g: 255, b: 255, a: 255 }; + + // Don't fill if target is same as fill color + if (targetR === fillColor.r && targetG === fillColor.g && + targetB === fillColor.b && targetA === fillColor.a) { + return; + } + + // Flood fill using stack-based algorithm + const stack: [number, number][] = [[x, y]]; + const visited = new Set(); + + const matchesTarget = (idx: number) => { + return data[idx] === targetR && + data[idx + 1] === targetG && + data[idx + 2] === targetB && + data[idx + 3] === targetA; + }; + + while (stack.length > 0) { + const [cx, cy] = stack.pop()!; + const key = `${cx},${cy}`; + + if (visited.has(key)) continue; + if (cx < 0 || cx >= canvasWidth || cy < 0 || cy >= canvasHeight) continue; + + const idx = (cy * canvasWidth + cx) * 4; + if (!matchesTarget(idx)) continue; + + visited.add(key); + + // Fill pixel + data[idx] = fillColor.r; + data[idx + 1] = fillColor.g; + data[idx + 2] = fillColor.b; + data[idx + 3] = fillColor.a; + + // Add neighbors + stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + } + + ctx.putImageData(imageData, 0, 0); + setHasDrawing(true); + + // Save to history (capped at MAX_HISTORY entries) + const newImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + setHistory(prev => { + const trimmed = [...prev.slice(0, historyIndex + 1), newImageData]; + if (trimmed.length > MAX_HISTORY) { + const overflow = trimmed.length - MAX_HISTORY; + return trimmed.slice(overflow); + } + return trimmed; + }); + setHistoryIndex(prev => Math.min(prev + 1, MAX_HISTORY - 1)); + }, [isDrawingMode, isBucket, getCanvasPoint, brushColor, historyIndex]); + + const handleDrawStart = useCallback((e: React.MouseEvent) => { + if (!isDrawingMode) return; + // Bucket fill is handled separately + if (isBucket) { + handleBucketFill(e); + return; + } + e.preventDefault(); + e.stopPropagation(); + setIsDrawing(true); + if (!isEraser) setHasDrawing(true); + const point = getCanvasPoint(e); + lastPointRef.current = point; + + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + if (isEraser) { + // Eraser: clear to transparent (reveals widgets underneath) + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.arc(point.x, point.y, brushSize / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.globalCompositeOperation = 'source-over'; + } else { + // Draw: paint with color + ctx.beginPath(); + ctx.arc(point.x, point.y, brushSize / 2, 0, Math.PI * 2); + ctx.fillStyle = brushColor; + ctx.fill(); + } + }, [isDrawingMode, isBucket, handleBucketFill, getCanvasPoint, brushSize, brushColor, isEraser]); + + // Window-level move handler for drawing outside canvas bounds + const handleWindowDrawMove = useCallback((e: MouseEvent) => { + if (!isDrawingMode || !isDrawing) return; + + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const point = getCanvasPoint(e); + const lastPoint = lastPointRef.current; + if (lastPoint) { + if (isEraser) { + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.moveTo(lastPoint.x, lastPoint.y); + ctx.lineTo(point.x, point.y); + ctx.lineWidth = brushSize; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + ctx.globalCompositeOperation = 'source-over'; + } else { + ctx.beginPath(); + ctx.moveTo(lastPoint.x, lastPoint.y); + ctx.lineTo(point.x, point.y); + ctx.strokeStyle = brushColor; + ctx.lineWidth = brushSize; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + } + lastPointRef.current = point; + }, [isDrawingMode, isDrawing, getCanvasPoint, brushSize, brushColor, isEraser]); + + const handleDrawEnd = useCallback(() => { + if (isDrawing) { + // Save to history after drawing stroke ends + const canvas = drawingCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + setHistory(prev => { + const trimmed = [...prev.slice(0, historyIndex + 1), imageData]; + if (trimmed.length > MAX_HISTORY) { + const overflow = trimmed.length - MAX_HISTORY; + return trimmed.slice(overflow); + } + return trimmed; + }); + setHistoryIndex(prev => Math.min(prev + 1, MAX_HISTORY - 1)); + } + } + } + setIsDrawing(false); + lastPointRef.current = null; + }, [isDrawing, historyIndex]); + + // Attach window-level events when drawing to continue drawing outside canvas + useEffect(() => { + if (isDrawing) { + window.addEventListener('mousemove', handleWindowDrawMove); + window.addEventListener('mouseup', handleDrawEnd); + return () => { + window.removeEventListener('mousemove', handleWindowDrawMove); + window.removeEventListener('mouseup', handleDrawEnd); + }; + } + }, [isDrawing, handleWindowDrawMove, handleDrawEnd]); + + // Save initial state to history when drawing mode is enabled + useEffect(() => { + if (isDrawingMode && history.length === 0) { + const canvas = drawingCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + setHistory([imageData]); + setHistoryIndex(0); + } + } + } + }, [isDrawingMode, history.length]); + + // Undo function + const handleUndo = useCallback(() => { + if (historyIndex <= 0) return; + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const newIndex = historyIndex - 1; + ctx.putImageData(history[newIndex], 0, 0); + setHistoryIndex(newIndex); + }, [history, historyIndex]); + + // Redo function + const handleRedo = useCallback(() => { + if (historyIndex >= history.length - 1) return; + const canvas = drawingCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const newIndex = historyIndex + 1; + ctx.putImageData(history[newIndex], 0, 0); + setHistoryIndex(newIndex); + }, [history, historyIndex]); + + // Keyboard shortcuts for undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isDrawingMode) return; + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { + e.preventDefault(); + if (e.shiftKey) { + handleRedo(); + } else { + handleUndo(); + } + } + if ((e.ctrlKey || e.metaKey) && e.key === 'y') { + e.preventDefault(); + handleRedo(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDrawingMode, handleUndo, handleRedo]); + + return ( +
    + {/* Toolbar */} +
    +
    + {/* Grid toggle */} + + + {/* Snap indicator */} +
    + + + + Snap +
    + +
    + + {/* Draw mode */} + + + {isDrawingMode && ( + <> +
    + {/* Color and tool selection */} +
    + + +
    + setBrushSize(parseInt(e.target.value))} className="w-16 h-1 accent-accent" title="Brush size" /> + {brushSize}px + +
    + + {/* Undo/Redo buttons */} +
    + + +
    + +
    + + + + + )} +
    + +
    + {width} × {height} + {widgets.length} widget{widgets.length !== 1 ? 's' : ''} +
    +
    + + {/* Canvas */} +
    +
    {/* pt-8 adds space for labels above canvas */} + {/* Canvas container - widgets can overflow for visibility in designer */} +
    +
    + {/* Grid */} + {showGrid && ( +
    + )} + + {/* Drawing canvas - rendered BEFORE widgets so it's behind them in DOM order */} + {/* When in drawing mode, it gets higher z-index to allow drawing on top */} + + + {/* Snap guides */} + {isWidgetDragging && ( +
    + {activeGuides.vertical.map((x, i) => ( +
    + ))} + {activeGuides.horizontal.map((y, i) => ( +
    + ))} +
    + )} + + {/* Widgets container - anti-aliasing disabled for pixel-perfect e-ink rendering */} + {/* Background is transparent so drawing canvas shows through */} + {/* overflow-visible so designer shows full widgets, device will clip */} +
    + {widgets.map((widget) => { + const template = getTemplate(widget.templateId); + if (!template) return null; + return ( + w.id !== widget.id)} + snapThreshold={8} + onSelect={() => onSelectWidget(widget.id)} + onMove={(x, y) => onUpdateWidget(widget.id, { x, y })} + onResize={(w, h) => onUpdateWidget(widget.id, { width: w, height: h })} + onRotate={(rotation) => onUpdateWidget(widget.id, { rotation })} + onDelete={() => onDeleteWidget(widget.id)} + onDragStateChange={handleWidgetDragStateChange} + /> + ); + })} + + {/* Empty state */} + {widgets.length === 0 && !isDragOver && ( +
    +
    +

    Drop widgets here

    +

    Drag from the sidebar

    +
    +
    + )} + + {/* Drop indicator */} + {isDragOver && ( +
    +

    Drop to add

    +
    + )} +
    +
    + + {/* Widget labels overlay - always visible above everything */} +
    + {widgets.map((widget) => { + const template = getTemplate(widget.templateId); + if (!template) return null; + + const isSelected = selectedWidgetId === widget.id; + + // Position label above widget, or below if near top edge + const labelAbove = widget.y >= 30; + const labelTop = labelAbove ? widget.y - 24 : widget.y + widget.height + 4; + + return ( +
    + {template.label} +
    + ); + })} +
    +
    +
    +
    + + {/* Status bar */} +
    + {width} × {height}px +
    + {hasDrawing && ( +
    + + + + Drawing overlay +
    + )} +
    + + Ready +
    +
    +
    +
    + ); +}); diff --git a/frontend/src/components/screen-designer/DraggableWidget.tsx b/frontend/src/components/screen-designer/DraggableWidget.tsx new file mode 100644 index 0000000..793cfa9 --- /dev/null +++ b/frontend/src/components/screen-designer/DraggableWidget.tsx @@ -0,0 +1,661 @@ +/** + * DraggableWidget Component + * Wrapper for widgets on the canvas that handles dragging and resizing. + * Uses refs to access DOM elements for measuring and positioning, + * and handles mouse events to implement drag and resize functionality. + */ +import { useState, useRef, useCallback } from 'react'; +import type { ScreenWidget, WidgetTemplate } from '../../types'; +import { WidgetRenderer } from './WidgetRenderer'; + +// Snap guide lines configuration +export interface SnapGuide { + position: number; // Position in pixels + type: 'center' | 'third' | 'edge' | 'widget'; + orientation: 'horizontal' | 'vertical'; +} + +export interface ActiveGuides { + vertical: number[]; // X positions of active vertical guides + horizontal: number[]; // Y positions of active horizontal guides + widgetVertical: number[]; // X positions from other widgets + widgetHorizontal: number[]; // Y positions from other widgets +} + +interface DraggableWidgetProps { + widget: ScreenWidget; + template: WidgetTemplate; + isSelected: boolean; + scale: number; + canvasWidth: number; + canvasHeight: number; + otherWidgets?: ScreenWidget[]; // Other widgets for widget-to-widget snapping + snapThreshold?: number; // Distance to snap (default 8px) + onSelect: () => void; + onMove: (x: number, y: number) => void; + onResize: (width: number, height: number) => void; + onRotate: (rotation: number) => void; + onDelete: () => void; + onDragStateChange?: (isDragging: boolean, activeGuides: ActiveGuides) => void; +} + +type ResizeHandle = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w'; + +export function DraggableWidget({ + widget, + template, + isSelected, + scale, + canvasWidth, + canvasHeight, + otherWidgets = [], + snapThreshold = 8, + onSelect, + onMove, + onResize, + onRotate, + onDelete, + onDragStateChange, +}: DraggableWidgetProps) { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [isRotating, setIsRotating] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const dragStartRef = useRef({ x: 0, y: 0 }); + const widgetStartRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + const rotationStartRef = useRef({ angle: 0, startRotation: 0 }); + + /** + * Calculate snap guides based on canvas dimensions and other widgets + * Returns arrays of vertical (X) and horizontal (Y) snap positions + */ + const getSnapGuides = useCallback(() => { + // Canvas-based guides + const verticalGuides = [ + 0, // Left edge + canvasWidth / 3, // Left third + canvasWidth / 2, // Center + (canvasWidth * 2) / 3, // Right third + canvasWidth, // Right edge + ]; + const horizontalGuides = [ + 0, // Top edge + canvasHeight / 3, // Top third + canvasHeight / 2, // Center + (canvasHeight * 2) / 3, // Bottom third + canvasHeight, // Bottom edge + ]; + + // Widget-based guides (from other widgets' edges and centers) + const widgetVerticalGuides: number[] = []; + const widgetHorizontalGuides: number[] = []; + + for (const other of otherWidgets) { + // Vertical guides from other widget (X positions) + widgetVerticalGuides.push(other.x); // Left edge + widgetVerticalGuides.push(other.x + other.width / 2); // Center X + widgetVerticalGuides.push(other.x + other.width); // Right edge + + // Horizontal guides from other widget (Y positions) + widgetHorizontalGuides.push(other.y); // Top edge + widgetHorizontalGuides.push(other.y + other.height / 2); // Center Y + widgetHorizontalGuides.push(other.y + other.height); // Bottom edge + } + + return { + verticalGuides, + horizontalGuides, + widgetVerticalGuides, + widgetHorizontalGuides, + }; + }, [canvasWidth, canvasHeight, otherWidgets]); + + /** + * Calculate snapped position and active guides + * Checks widget edges and center against canvas guides and other widgets + */ + const calculateSnappedPosition = useCallback( + (x: number, y: number, width: number, height: number) => { + const { verticalGuides, horizontalGuides, widgetVerticalGuides, widgetHorizontalGuides } = getSnapGuides(); + const activeVertical: number[] = []; + const activeHorizontal: number[] = []; + const activeWidgetVertical: number[] = []; + const activeWidgetHorizontal: number[] = []; + + let snappedX = x; + let snappedY = y; + let snapXFound = false; + let snapYFound = false; + + // Widget edges and center for X axis + const widgetLeft = x; + const widgetCenterX = x + width / 2; + const widgetRight = x + width; + + // Widget edges and center for Y axis + const widgetTop = y; + const widgetCenterY = y + height / 2; + const widgetBottom = y + height; + + // Check canvas vertical guides (X positions) first + for (const guide of verticalGuides) { + if (snapXFound) break; + // Snap left edge + if (Math.abs(widgetLeft - guide) < snapThreshold) { + snappedX = guide; + activeVertical.push(guide); + snapXFound = true; + } + // Snap center X + else if (Math.abs(widgetCenterX - guide) < snapThreshold) { + snappedX = guide - width / 2; + activeVertical.push(guide); + snapXFound = true; + } + // Snap right edge + else if (Math.abs(widgetRight - guide) < snapThreshold) { + snappedX = guide - width; + activeVertical.push(guide); + snapXFound = true; + } + } + + // Check widget vertical guides if no canvas snap found + if (!snapXFound) { + for (const guide of widgetVerticalGuides) { + if (snapXFound) break; + // Snap left edge to widget + if (Math.abs(widgetLeft - guide) < snapThreshold) { + snappedX = guide; + activeWidgetVertical.push(guide); + snapXFound = true; + } + // Snap center X to widget + else if (Math.abs(widgetCenterX - guide) < snapThreshold) { + snappedX = guide - width / 2; + activeWidgetVertical.push(guide); + snapXFound = true; + } + // Snap right edge to widget + else if (Math.abs(widgetRight - guide) < snapThreshold) { + snappedX = guide - width; + activeWidgetVertical.push(guide); + snapXFound = true; + } + } + } + + // Check canvas horizontal guides (Y positions) first + for (const guide of horizontalGuides) { + if (snapYFound) break; + // Snap top edge + if (Math.abs(widgetTop - guide) < snapThreshold) { + snappedY = guide; + activeHorizontal.push(guide); + snapYFound = true; + } + // Snap center Y + else if (Math.abs(widgetCenterY - guide) < snapThreshold) { + snappedY = guide - height / 2; + activeHorizontal.push(guide); + snapYFound = true; + } + // Snap bottom edge + else if (Math.abs(widgetBottom - guide) < snapThreshold) { + snappedY = guide - height; + activeHorizontal.push(guide); + snapYFound = true; + } + } + + // Check widget horizontal guides if no canvas snap found + if (!snapYFound) { + for (const guide of widgetHorizontalGuides) { + if (snapYFound) break; + // Snap top edge to widget + if (Math.abs(widgetTop - guide) < snapThreshold) { + snappedY = guide; + activeWidgetHorizontal.push(guide); + snapYFound = true; + } + // Snap center Y to widget + else if (Math.abs(widgetCenterY - guide) < snapThreshold) { + snappedY = guide - height / 2; + activeWidgetHorizontal.push(guide); + snapYFound = true; + } + // Snap bottom edge to widget + else if (Math.abs(widgetBottom - guide) < snapThreshold) { + snappedY = guide - height; + activeWidgetHorizontal.push(guide); + snapYFound = true; + } + } + } + + return { + x: snappedX, + y: snappedY, + activeGuides: { + vertical: activeVertical, + horizontal: activeHorizontal, + widgetVertical: activeWidgetVertical, + widgetHorizontal: activeWidgetHorizontal, + }, + }; + }, + [getSnapGuides, snapThreshold] + ); + + /** + * Handle click on widget to select it + * Uses e.stopPropagation() to prevent the click from bubbling + * to the canvas and deselecting the widget + */ + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(); + }, + [onSelect] + ); + + /** + * Handle mouse down on widget for dragging + */ + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + onSelect(); + + setIsDragging(true); + dragStartRef.current = { x: e.clientX, y: e.clientY }; + widgetStartRef.current = { + x: widget.x, + y: widget.y, + width: widget.width, + height: widget.height, + }; + + // Notify parent that dragging started + onDragStateChange?.(true, { vertical: [], horizontal: [], widgetVertical: [], widgetHorizontal: [] }); + + const handleMouseMove = (e: MouseEvent) => { + const dx = (e.clientX - dragStartRef.current.x) / scale; + const dy = (e.clientY - dragStartRef.current.y) / scale; + + // Allow widgets to be placed anywhere (no boundary restrictions) + const rawX = widgetStartRef.current.x + dx; + const rawY = widgetStartRef.current.y + dy; + + // Calculate snapped position and active guides + const snapped = calculateSnappedPosition( + rawX, + rawY, + widget.width, + widget.height + ); + + // Notify parent about active guides for rendering + onDragStateChange?.(true, snapped.activeGuides); + + onMove(Math.round(snapped.x), Math.round(snapped.y)); + }; + + const handleMouseUp = () => { + setIsDragging(false); + // Notify parent that dragging ended + onDragStateChange?.(false, { vertical: [], horizontal: [], widgetVertical: [], widgetHorizontal: [] }); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [widget.x, widget.y, widget.width, widget.height, scale, onSelect, onMove, onDragStateChange, calculateSnappedPosition] + ); + + /** + * Handle mouse down on resize handle + */ + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent, handle: ResizeHandle) => { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + + setIsResizing(true); + dragStartRef.current = { x: e.clientX, y: e.clientY }; + widgetStartRef.current = { + x: widget.x, + y: widget.y, + width: widget.width, + height: widget.height, + }; + + const handleMouseMove = (e: MouseEvent) => { + const dx = (e.clientX - dragStartRef.current.x) / scale; + const dy = (e.clientY - dragStartRef.current.y) / scale; + + let newWidth = widgetStartRef.current.width; + let newHeight = widgetStartRef.current.height; + let newX = widgetStartRef.current.x; + let newY = widgetStartRef.current.y; + + // Allow free resizing with a small minimum (10px) + const minSize = 10; + + switch (handle) { + case 'se': + newWidth = Math.max(minSize, widgetStartRef.current.width + dx); + newHeight = Math.max(minSize, widgetStartRef.current.height + dy); + break; + case 'sw': + newWidth = Math.max(minSize, widgetStartRef.current.width - dx); + newHeight = Math.max(minSize, widgetStartRef.current.height + dy); + newX = widgetStartRef.current.x + (widgetStartRef.current.width - newWidth); + break; + case 'ne': + newWidth = Math.max(minSize, widgetStartRef.current.width + dx); + newHeight = Math.max(minSize, widgetStartRef.current.height - dy); + newY = widgetStartRef.current.y + (widgetStartRef.current.height - newHeight); + break; + case 'nw': + newWidth = Math.max(minSize, widgetStartRef.current.width - dx); + newHeight = Math.max(minSize, widgetStartRef.current.height - dy); + newX = widgetStartRef.current.x + (widgetStartRef.current.width - newWidth); + newY = widgetStartRef.current.y + (widgetStartRef.current.height - newHeight); + break; + case 'n': + newHeight = Math.max(minSize, widgetStartRef.current.height - dy); + newY = widgetStartRef.current.y + (widgetStartRef.current.height - newHeight); + break; + case 's': + newHeight = Math.max(minSize, widgetStartRef.current.height + dy); + break; + case 'e': + newWidth = Math.max(minSize, widgetStartRef.current.width + dx); + break; + case 'w': + newWidth = Math.max(minSize, widgetStartRef.current.width - dx); + newX = widgetStartRef.current.x + (widgetStartRef.current.width - newWidth); + break; + } + + // Allow widgets to be positioned anywhere (no boundary restrictions) + if (newX !== widget.x || newY !== widget.y) { + onMove(Math.round(newX), Math.round(newY)); + } + onResize(Math.round(newWidth), Math.round(newHeight)); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [widget.x, widget.y, widget.width, widget.height, scale, onMove, onResize] + ); + + /** + * Handle mouse down on rotation handle + * Calculates rotation based on angle from widget center to mouse position + */ + const handleRotationMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + + setIsRotating(true); + + // Calculate widget center in screen coordinates + const widgetCenterX = widget.x + widget.width / 2; + const widgetCenterY = widget.y + widget.height / 2; + + // Get the canvas element to calculate proper offset + const target = e.currentTarget as HTMLElement; + const canvas = target.closest('[data-canvas]') as HTMLElement; + const canvasRect = canvas?.getBoundingClientRect() || { left: 0, top: 0 }; + + // Calculate initial angle from center to mouse + const mouseX = (e.clientX - canvasRect.left) / scale; + const mouseY = (e.clientY - canvasRect.top) / scale; + const initialAngle = Math.atan2(mouseY - widgetCenterY, mouseX - widgetCenterX) * (180 / Math.PI); + + rotationStartRef.current = { + angle: initialAngle, + startRotation: widget.rotation || 0, + }; + + const handleMouseMove = (e: MouseEvent) => { + const mouseX = (e.clientX - canvasRect.left) / scale; + const mouseY = (e.clientY - canvasRect.top) / scale; + + // Calculate current angle + const currentAngle = Math.atan2(mouseY - widgetCenterY, mouseX - widgetCenterX) * (180 / Math.PI); + const angleDelta = currentAngle - rotationStartRef.current.angle; + + // Calculate new rotation + let newRotation = rotationStartRef.current.startRotation + angleDelta; + + // Snap to 15-degree increments when holding Shift + if (e.shiftKey) { + newRotation = Math.round(newRotation / 15) * 15; + } + + // Normalize to 0-359 range (360 = 0) + newRotation = ((newRotation % 360) + 360) % 360; + if (newRotation === 360) newRotation = 0; + + onRotate(Math.round(newRotation)); + }; + + const handleMouseUp = () => { + setIsRotating(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [widget.x, widget.y, widget.width, widget.height, widget.rotation, scale, onRotate] + ); + + /** + * Handle keyboard events for deletion + */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isSelected && (e.key === 'Delete' || e.key === 'Backspace')) { + e.preventDefault(); + onDelete(); + } + }, + [isSelected, onDelete] + ); + + // Only bring widget to top when actively dragging, not just when selected + // This allows users to see all widgets while selecting/positioning + const widgetZIndex = isDragging || isResizing || isRotating ? 10000 : widget.zIndex; + + return ( +
    setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onKeyDown={handleKeyDown} + tabIndex={isSelected ? 0 : -1} + > + {/* Widget content - transparent background so drawing shows through */} + {/* When dragging: make widget semi-transparent to see other widgets and edges */} +
    + +
    + + {/* Selection label is now rendered in DesignCanvas overlay for visibility */} + + {/* Resize handles - always on top when selected */} + {isSelected && ( + <> + + + + + + + + + + )} + + {/* Rotation handle - circular knob below widget, always on top */} + {isSelected && ( +
    + {/* Connecting line */} +
    + {/* Rotation knob */} +
    e.stopPropagation()} + onMouseDown={handleRotationMouseDown} + > + {/* Rotate icon */} + + + + +
    +
    + )} + + {/* Size indicator when resizing */} + {isResizing && ( +
    + {Math.round(widget.width)} x {Math.round(widget.height)} +
    + )} + + {/* Position indicator when dragging */} + {isDragging && ( +
    + {Math.round(widget.x)}, {Math.round(widget.y)} +
    + )} + + {/* Rotation indicator when rotating */} + {isRotating && ( +
    + {Math.round(widget.rotation || 0)}° +
    + )} +
    + ); +} + +/** + * Corner resize handle component + */ +function ResizeHandleCorner({ + position, + onMouseDown, +}: { + position: 'nw' | 'ne' | 'sw' | 'se'; + onMouseDown: (e: React.MouseEvent, handle: ResizeHandle) => void; +}) { + const positionStyles: Record = { + nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize', + ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize', + sw: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize', + se: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize', + }; + + return ( +
    e.stopPropagation()} + onMouseDown={(e) => onMouseDown(e, position)} + /> + ); +} + +/** + * Edge resize handle component + */ +function ResizeHandleEdge({ + position, + onMouseDown, +}: { + position: 'n' | 's' | 'e' | 'w'; + onMouseDown: (e: React.MouseEvent, handle: ResizeHandle) => void; +}) { + const positionStyles: Record = { + n: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-n-resize', + s: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-s-resize', + e: 'right-0 top-1/2 translate-x-1/2 -translate-y-1/2 cursor-e-resize', + w: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-w-resize', + }; + + const isHorizontal = position === 'n' || position === 's'; + + return ( +
    e.stopPropagation()} + onMouseDown={(e) => onMouseDown(e, position)} + /> + ); +} diff --git a/frontend/src/components/screen-designer/ScreenDesignPreview.tsx b/frontend/src/components/screen-designer/ScreenDesignPreview.tsx new file mode 100644 index 0000000..5458b19 --- /dev/null +++ b/frontend/src/components/screen-designer/ScreenDesignPreview.tsx @@ -0,0 +1,107 @@ +/** + * ScreenDesignPreview Component + * Renders a 1:1 scaled-down preview of a screen design with its widgets. + * + * Uses CSS transform to scale the entire design uniformly, ensuring + * the preview looks exactly like the designer - just smaller. + */ +import { useRef, useState, useEffect } from 'react'; +import type { ScreenDesign, WidgetTemplate } from '../../types'; +import { WidgetRenderer } from './WidgetRenderer'; + +interface ScreenDesignPreviewProps { + design: ScreenDesign; + templates: WidgetTemplate[]; + className?: string; +} + +export function ScreenDesignPreview({ design, templates, className = '' }: ScreenDesignPreviewProps) { + const containerRef = useRef(null); + const [scale, setScale] = useState(0.2); + + // Create a template lookup map for quick access + const templateMap = new Map(templates.map(t => [t.id, t])); + + // Calculate scale factor when container size changes + useEffect(() => { + const updateScale = () => { + if (containerRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const containerHeight = containerRef.current.offsetHeight; + + // Calculate scale to fit design in container + const scaleX = containerWidth / design.width; + const scaleY = containerHeight / design.height; + + // Use the smaller scale to ensure it fits + setScale(Math.min(scaleX, scaleY)); + } + }; + + updateScale(); + + // Update on resize + const resizeObserver = new ResizeObserver(updateScale); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, [design.width, design.height]); + + return ( +
    + {/* Container for the scaled design */} +
    + {/* Render each widget at actual size - transform handles scaling */} + {design.widgets.map((widget) => { + const template = templateMap.get(widget.templateId); + if (!template) return null; + + return ( +
    + {/* Use the actual WidgetRenderer for 1:1 fidelity */} + +
    + ); + })} + + {/* Show placeholder if no widgets */} + {design.widgets.length === 0 && ( +
    + Empty design +
    + )} +
    +
    + ); +} diff --git a/frontend/src/components/screen-designer/TimezoneSelectionModal.tsx b/frontend/src/components/screen-designer/TimezoneSelectionModal.tsx new file mode 100644 index 0000000..e3d8b6b --- /dev/null +++ b/frontend/src/components/screen-designer/TimezoneSelectionModal.tsx @@ -0,0 +1,270 @@ +/** + * TimezoneSelectionModal Component + * Shows a timezone picker popup when placing clock or date widgets + */ +import { useState, useMemo, useCallback } from 'react'; +import { Modal } from '../common/Modal'; +import { SearchableSelect } from '../common/SearchableSelect'; + +interface TimezoneSelectionModalProps { + isOpen: boolean; + onConfirm: (timezone: string) => void; + onUseDefault: () => void; + widgetType: 'clock' | 'date'; +} + +// Get the browser's detected timezone +const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Timezone data with city aliases for smart search +interface TimezoneData { + value: string; + label: string; + offset: string; + region: string; + cities: string[]; +} + +const timezoneData: TimezoneData[] = [ + // UTC + { value: 'UTC', label: 'UTC', offset: '±0:00', region: 'UTC', cities: ['utc', 'gmt', 'greenwich', 'coordinated universal time'] }, + + // Americas (West to East) + { value: 'Pacific/Honolulu', label: 'Hawaii', offset: '-10:00', region: 'Americas', cities: ['honolulu', 'hawaii', 'maui', 'oahu'] }, + { value: 'America/Anchorage', label: 'Alaska', offset: '-9:00', region: 'Americas', cities: ['anchorage', 'alaska', 'juneau', 'fairbanks'] }, + { value: 'America/Los_Angeles', label: 'Los Angeles', offset: '-8:00', region: 'Americas', cities: ['los angeles', 'la', 'san francisco', 'seattle', 'portland', 'san diego', 'las vegas', 'pacific', 'hollywood', 'sacramento', 'oakland'] }, + { value: 'America/Denver', label: 'Denver', offset: '-7:00', region: 'Americas', cities: ['denver', 'phoenix', 'salt lake', 'arizona', 'mountain', 'albuquerque', 'colorado', 'utah'] }, + { value: 'America/Chicago', label: 'Chicago', offset: '-6:00', region: 'Americas', cities: ['chicago', 'houston', 'dallas', 'austin', 'minneapolis', 'central', 'mexico city', 'guadalajara', 'san antonio', 'kansas city', 'milwaukee', 'oklahoma'] }, + { value: 'America/New_York', label: 'New York', offset: '-5:00', region: 'Americas', cities: ['new york', 'nyc', 'boston', 'miami', 'atlanta', 'philadelphia', 'washington', 'detroit', 'toronto', 'montreal', 'eastern', 'manhattan', 'brooklyn', 'baltimore', 'pittsburgh', 'charlotte', 'tampa', 'orlando', 'quebec'] }, + { value: 'America/Sao_Paulo', label: 'São Paulo', offset: '-3:00', region: 'Americas', cities: ['sao paulo', 'rio', 'rio de janeiro', 'brasilia', 'brazil', 'buenos aires', 'argentina', 'salvador', 'fortaleza', 'belo horizonte'] }, + + // Western Europe + { value: 'Europe/London', label: 'London', offset: '+0:00', region: 'Western Europe', cities: ['london', 'uk', 'united kingdom', 'britain', 'england', 'dublin', 'ireland', 'lisbon', 'portugal', 'edinburgh', 'manchester', 'birmingham', 'glasgow', 'liverpool', 'bristol', 'cardiff', 'belfast', 'leeds', 'sheffield', 'porto', 'cork'] }, + { value: 'Europe/Paris', label: 'Paris', offset: '+1:00', region: 'Western Europe', cities: ['paris', 'france', 'lyon', 'marseille', 'nice', 'monaco', 'toulouse', 'bordeaux', 'lille', 'nantes', 'strasbourg', 'montpellier'] }, + { value: 'Europe/Amsterdam', label: 'Amsterdam', offset: '+1:00', region: 'Western Europe', cities: ['amsterdam', 'netherlands', 'holland', 'rotterdam', 'hague', 'brussels', 'belgium', 'antwerp', 'utrecht', 'eindhoven', 'ghent', 'bruges', 'liege', 'luxembourg'] }, + { value: 'Europe/Madrid', label: 'Madrid', offset: '+1:00', region: 'Western Europe', cities: ['madrid', 'spain', 'barcelona', 'valencia', 'seville', 'malaga', 'bilbao', 'zaragoza', 'palma', 'canary islands', 'tenerife', 'mallorca', 'ibiza'] }, + + // Central Europe + { value: 'Europe/Berlin', label: 'Berlin', offset: '+1:00', region: 'Central Europe', cities: ['berlin', 'germany', 'munich', 'frankfurt', 'hamburg', 'cologne', 'dusseldorf', 'stuttgart', 'dortmund', 'essen', 'bremen', 'leipzig', 'dresden', 'hannover', 'nuremberg', 'bonn'] }, + { value: 'Europe/Rome', label: 'Rome', offset: '+1:00', region: 'Central Europe', cities: ['rome', 'italy', 'milan', 'florence', 'venice', 'naples', 'turin', 'vatican', 'bologna', 'genoa', 'palermo', 'verona', 'pisa', 'siena'] }, + { value: 'Europe/Vienna', label: 'Vienna', offset: '+1:00', region: 'Central Europe', cities: ['vienna', 'austria', 'salzburg', 'innsbruck', 'graz', 'linz'] }, + { value: 'Europe/Zurich', label: 'Zurich', offset: '+1:00', region: 'Central Europe', cities: ['zurich', 'zürich', 'switzerland', 'geneva', 'bern', 'basel', 'lausanne', 'lucerne', 'interlaken', 'davos'] }, + { value: 'Europe/Warsaw', label: 'Warsaw', offset: '+1:00', region: 'Central Europe', cities: ['warsaw', 'warszawa', 'poland', 'polska', 'krakow', 'kraków', 'cracow', 'lodz', 'łódź', 'wroclaw', 'wrocław', 'breslau', 'poznan', 'poznań', 'gdansk', 'gdańsk', 'danzig', 'szczecin', 'stettin', 'lublin', 'katowice', 'bialystok', 'białystok', 'czestochowa', 'częstochowa', 'radom', 'torun', 'toruń', 'kielce', 'rzeszow', 'rzeszów', 'olsztyn', 'bydgoszcz', 'gliwice', 'zabrze', 'bytom', 'opole', 'zielona gora', 'zielona góra', 'sopot', 'gdynia', 'zakopane', 'plock', 'płock', 'legnica', 'elblag', 'elbląg', 'tarnow', 'tarnów', 'gorzow', 'gorzów'] }, + { value: 'Europe/Prague', label: 'Prague', offset: '+1:00', region: 'Central Europe', cities: ['prague', 'praha', 'czech', 'czechia', 'brno', 'ostrava', 'bratislava', 'slovakia', 'kosice', 'pilsen', 'olomouc', 'liberec'] }, + { value: 'Europe/Budapest', label: 'Budapest', offset: '+1:00', region: 'Central Europe', cities: ['budapest', 'hungary', 'debrecen', 'szeged', 'pecs', 'gyor'] }, + + // Northern Europe + { value: 'Europe/Stockholm', label: 'Stockholm', offset: '+1:00', region: 'Northern Europe', cities: ['stockholm', 'sweden', 'gothenburg', 'malmo', 'goteborg', 'uppsala'] }, + { value: 'Europe/Oslo', label: 'Oslo', offset: '+1:00', region: 'Northern Europe', cities: ['oslo', 'norway', 'bergen', 'trondheim', 'stavanger', 'tromso'] }, + { value: 'Europe/Copenhagen', label: 'Copenhagen', offset: '+1:00', region: 'Northern Europe', cities: ['copenhagen', 'denmark', 'aarhus', 'odense', 'aalborg'] }, + { value: 'Europe/Helsinki', label: 'Helsinki', offset: '+2:00', region: 'Northern Europe', cities: ['helsinki', 'finland', 'tampere', 'turku', 'oulu', 'espoo', 'vantaa'] }, + + // Eastern Europe + { value: 'Europe/Athens', label: 'Athens', offset: '+2:00', region: 'Eastern Europe', cities: ['athens', 'greece', 'thessaloniki', 'santorini', 'crete', 'mykonos', 'rhodes', 'corfu', 'patras'] }, + { value: 'Europe/Bucharest', label: 'Bucharest', offset: '+2:00', region: 'Eastern Europe', cities: ['bucharest', 'romania', 'cluj', 'timisoara', 'iasi', 'constanta', 'brasov', 'sibiu'] }, + { value: 'Europe/Sofia', label: 'Sofia', offset: '+2:00', region: 'Eastern Europe', cities: ['sofia', 'bulgaria', 'plovdiv', 'varna', 'burgas'] }, + { value: 'Europe/Tallinn', label: 'Tallinn', offset: '+2:00', region: 'Eastern Europe', cities: ['tallinn', 'estonia', 'tartu'] }, + { value: 'Europe/Riga', label: 'Riga', offset: '+2:00', region: 'Eastern Europe', cities: ['riga', 'latvia', 'jurmala'] }, + { value: 'Europe/Vilnius', label: 'Vilnius', offset: '+2:00', region: 'Eastern Europe', cities: ['vilnius', 'lithuania', 'kaunas', 'klaipeda'] }, + { value: 'Europe/Kyiv', label: 'Kyiv', offset: '+2:00', region: 'Eastern Europe', cities: ['kyiv', 'kiev', 'ukraine', 'kharkiv', 'odessa', 'lviv', 'dnipro', 'donetsk', 'zaporizhzhia'] }, + { value: 'Europe/Istanbul', label: 'Istanbul', offset: '+3:00', region: 'Eastern Europe', cities: ['istanbul', 'turkey', 'ankara', 'izmir', 'antalya', 'bursa', 'adana', 'bodrum', 'cappadocia'] }, + { value: 'Europe/Moscow', label: 'Moscow', offset: '+3:00', region: 'Eastern Europe', cities: ['moscow', 'russia', 'st petersburg', 'saint petersburg', 'nizhny novgorod', 'yekaterinburg', 'novosibirsk', 'kazan', 'samara', 'sochi'] }, + + // Middle East & Africa + { value: 'Africa/Cairo', label: 'Cairo', offset: '+2:00', region: 'Middle East & Africa', cities: ['cairo', 'egypt', 'alexandria', 'giza', 'luxor', 'aswan', 'sharm el sheikh', 'hurghada'] }, + { value: 'Africa/Johannesburg', label: 'Johannesburg', offset: '+2:00', region: 'Middle East & Africa', cities: ['johannesburg', 'south africa', 'cape town', 'durban', 'pretoria', 'port elizabeth', 'soweto'] }, + { value: 'Asia/Jerusalem', label: 'Jerusalem', offset: '+2:00', region: 'Middle East & Africa', cities: ['jerusalem', 'israel', 'tel aviv', 'haifa', 'eilat', 'nazareth'] }, + { value: 'Asia/Dubai', label: 'Dubai', offset: '+4:00', region: 'Middle East & Africa', cities: ['dubai', 'uae', 'abu dhabi', 'sharjah', 'doha', 'qatar', 'bahrain', 'manama', 'muscat', 'oman', 'ajman'] }, + { value: 'Asia/Riyadh', label: 'Riyadh', offset: '+3:00', region: 'Middle East & Africa', cities: ['riyadh', 'saudi arabia', 'jeddah', 'mecca', 'medina', 'kuwait', 'dammam'] }, + + // South Asia + { value: 'Asia/Karachi', label: 'Karachi', offset: '+5:00', region: 'South Asia', cities: ['karachi', 'pakistan', 'lahore', 'islamabad', 'rawalpindi', 'faisalabad', 'peshawar'] }, + { value: 'Asia/Kolkata', label: 'Mumbai', offset: '+5:30', region: 'South Asia', cities: ['mumbai', 'bombay', 'india', 'new delhi', 'delhi', 'bangalore', 'bengaluru', 'chennai', 'hyderabad', 'kolkata', 'calcutta', 'pune', 'ahmedabad', 'jaipur', 'goa', 'lucknow', 'kanpur', 'agra', 'varanasi', 'surat'] }, + { value: 'Asia/Dhaka', label: 'Dhaka', offset: '+6:00', region: 'South Asia', cities: ['dhaka', 'bangladesh', 'chittagong', 'khulna', 'sylhet'] }, + + // Southeast Asia + { value: 'Asia/Bangkok', label: 'Bangkok', offset: '+7:00', region: 'Southeast Asia', cities: ['bangkok', 'thailand', 'phuket', 'chiang mai', 'pattaya', 'hanoi', 'vietnam', 'ho chi minh', 'saigon', 'da nang', 'krabi', 'koh samui', 'hoi an'] }, + { value: 'Asia/Jakarta', label: 'Jakarta', offset: '+7:00', region: 'Southeast Asia', cities: ['jakarta', 'indonesia', 'bali', 'surabaya', 'bandung', 'yogyakarta', 'medan', 'lombok'] }, + { value: 'Asia/Singapore', label: 'Singapore', offset: '+8:00', region: 'Southeast Asia', cities: ['singapore', 'kuala lumpur', 'malaysia', 'penang', 'johor bahru', 'langkawi', 'ipoh', 'malacca'] }, + { value: 'Asia/Manila', label: 'Manila', offset: '+8:00', region: 'Southeast Asia', cities: ['manila', 'philippines', 'cebu', 'davao', 'boracay', 'makati', 'palawan', 'baguio'] }, + + // East Asia + { value: 'Asia/Shanghai', label: 'Shanghai', offset: '+8:00', region: 'East Asia', cities: ['shanghai', 'china', 'beijing', 'peking', 'guangzhou', 'shenzhen', 'hong kong', 'macau', 'chengdu', 'hangzhou', 'nanjing', 'wuhan', 'xian', 'suzhou', 'tianjin', 'chongqing', 'qingdao', 'dalian', 'harbin', 'kunming', 'guilin'] }, + { value: 'Asia/Taipei', label: 'Taipei', offset: '+8:00', region: 'East Asia', cities: ['taipei', 'taiwan', 'kaohsiung', 'taichung', 'tainan'] }, + { value: 'Asia/Tokyo', label: 'Tokyo', offset: '+9:00', region: 'East Asia', cities: ['tokyo', 'japan', 'osaka', 'kyoto', 'yokohama', 'nagoya', 'sapporo', 'fukuoka', 'kobe', 'sendai', 'hiroshima', 'nara', 'okinawa', 'hokkaido', 'shibuya', 'shinjuku'] }, + { value: 'Asia/Seoul', label: 'Seoul', offset: '+9:00', region: 'East Asia', cities: ['seoul', 'korea', 'south korea', 'busan', 'incheon', 'daegu', 'daejeon', 'gangnam', 'jeju'] }, + + // Oceania + { value: 'Australia/Perth', label: 'Perth', offset: '+8:00', region: 'Oceania', cities: ['perth', 'western australia', 'fremantle'] }, + { value: 'Australia/Sydney', label: 'Sydney', offset: '+10:00', region: 'Oceania', cities: ['sydney', 'australia', 'melbourne', 'brisbane', 'gold coast', 'canberra', 'adelaide', 'hobart', 'cairns', 'darwin', 'tasmania', 'bondi', 'byron bay', 'great barrier reef'] }, + { value: 'Pacific/Auckland', label: 'Auckland', offset: '+12:00', region: 'Oceania', cities: ['auckland', 'new zealand', 'wellington', 'christchurch', 'queenstown', 'rotorua', 'dunedin', 'hamilton', 'tauranga'] }, +]; + +// Define regions in display order +const regionOrder = [ + 'UTC', + 'Americas', + 'Western Europe', + 'Central Europe', + 'Northern Europe', + 'Eastern Europe', + 'Middle East & Africa', + 'South Asia', + 'Southeast Asia', + 'East Asia', + 'Oceania', +]; + +// Build timezone options for display with region grouping +const getTimezoneOptions = (): { value: string; label: string; hint?: string; isGroup?: boolean }[] => { + const result: { value: string; label: string; hint?: string; isGroup?: boolean }[] = []; + + // Check if browser timezone is not in the list, add it at the top + const browserTzInList = timezoneData.some(tz => tz.value === browserTimezone); + if (!browserTzInList) { + result.push({ value: browserTimezone, label: `${browserTimezone} (detected)` }); + } + + // Group timezones by region + for (const region of regionOrder) { + const regionTimezones = timezoneData.filter(tz => tz.region === region); + if (regionTimezones.length > 0) { + result.push({ value: region, label: region, isGroup: true }); + for (const tz of regionTimezones) { + result.push({ + value: tz.value, + label: `${tz.label} (UTC${tz.offset})`, + }); + } + } + } + + return result; +}; + +// Search function that matches cities to timezones with hints +const searchTimezones = (query: string): { value: string; label: string; hint?: string; isGroup?: boolean }[] => { + const q = query.toLowerCase().trim(); + if (!q) return getTimezoneOptions(); + + const matches: { value: string; label: string; hint?: string; score: number }[] = []; + + for (const tz of timezoneData) { + let score = 0; + let matchedCity: string | undefined; + + // Exact label match (highest priority) + if (tz.label.toLowerCase() === q) { + score = 100; + } + // Label starts with query + else if (tz.label.toLowerCase().startsWith(q)) { + score = 80; + } + // Label contains query + else if (tz.label.toLowerCase().includes(q)) { + score = 60; + } + // City exact match + else { + for (const city of tz.cities) { + if (city === q) { + score = 90; + matchedCity = city; + break; + } else if (city.startsWith(q)) { + if (score < 70) { + score = 70; + matchedCity = city; + } + } else if (city.includes(q)) { + if (score < 50) { + score = 50; + matchedCity = city; + } + } + } + } + + if (score > 0) { + matches.push({ + value: tz.value, + label: `${tz.label} (UTC${tz.offset})`, + hint: matchedCity, + score, + }); + } + } + + // Sort by score (highest first) + matches.sort((a, b) => b.score - a.score); + + return matches; +}; + +export function TimezoneSelectionModal({ + isOpen, + onConfirm, + onUseDefault, + widgetType, +}: TimezoneSelectionModalProps) { + const [selectedTimezone, setSelectedTimezone] = useState(browserTimezone); + + const options = useMemo(() => getTimezoneOptions(), []); + + const handleConfirm = useCallback(() => { + onConfirm(selectedTimezone); + setSelectedTimezone(browserTimezone); // Reset for next use + }, [selectedTimezone, onConfirm]); + + const handleUseDefault = useCallback(() => { + onUseDefault(); + setSelectedTimezone(browserTimezone); // Reset for next use + }, [onUseDefault]); + + const widgetLabel = widgetType === 'clock' ? 'Live Clock' : 'Date Display'; + + return ( + + + + + } + > +
    +

    + Select the timezone for this {widgetType === 'clock' ? 'clock' : 'date'} widget. + You can search by city name or region. +

    + + + +

    + Tip: Type a city name like "Tokyo", "London", or "Warsaw" to quickly find the timezone. +

    +
    +
    + ); +} diff --git a/frontend/src/components/screen-designer/WidgetLibrary.tsx b/frontend/src/components/screen-designer/WidgetLibrary.tsx new file mode 100644 index 0000000..151dabc --- /dev/null +++ b/frontend/src/components/screen-designer/WidgetLibrary.tsx @@ -0,0 +1,361 @@ +/** + * WidgetLibrary Component + * Displays available widget templates that can be dragged onto the canvas. + */ +import { useState } from 'react'; +import type { WidgetTemplate } from '../../types'; + +interface WidgetLibraryProps { + templates: WidgetTemplate[]; + onDragStart: (template: WidgetTemplate) => void; +} + +// Category configuration with icons +const categoryConfig: Record = { + time: { + label: 'Time & Date', + icon: ( + + + + ), + }, + weather: { + label: 'Weather', + icon: ( + + + + ), + }, + content: { + label: 'Content', + icon: ( + + + + ), + }, + system: { + label: 'System', + icon: ( + + + + ), + }, + layout: { + label: 'Layout', + icon: ( + + + + ), + }, + custom: { + label: 'Custom', + icon: ( + + + + ), + }, +}; + +// Widget-specific icons +const widgetIcons: Record = { + clock: ( + + + + ), + date: ( + + + + ), + weather: ( + + + + ), + text: ( + + + + ), + qrcode: ( + + + + ), + battery: ( + // Battery icon - matches WidgetRenderer style + + + + + + ), + image: ( + + + + ), + divider: ( + + + + ), + countdown: ( + + + + ), + rectangle: ( + + + + ), + wifi: ( + + + + ), + deviceinfo: ( + + + + ), + daysuntil: ( + + + + + ), + github: ( + // GitHub logo - MIT license from Simple Icons + + + + ), + // Custom widgets get a star icon to indicate they're user-created + custom: ( + + + + ), +}; + +export function WidgetLibrary({ templates, onDragStart }: WidgetLibraryProps) { + const [expandedCategories, setExpandedCategories] = useState>(() => { + const initial: Record = {}; + Object.keys(categoryConfig).forEach((key) => { + initial[key] = true; + }); + return initial; + }); + const [draggingTemplate, setDraggingTemplate] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + const templateArray = Array.isArray(templates) ? templates : []; + + const filteredTemplates = searchQuery + ? templateArray.filter( + (template) => + template.label.toLowerCase().includes(searchQuery.toLowerCase()) || + template.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : templateArray; + + const groupedTemplates = filteredTemplates.reduce>((acc, template) => { + const category = template.category; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(template); + return acc; + }, {}); + + /** Toggle category expansion */ + function toggleCategory(category: string) { + setExpandedCategories((prev) => ({ + ...prev, + [category]: !prev[category], + })); + } + + /** Handle drag start event */ + function handleDragStart(e: React.DragEvent, template: WidgetTemplate) { + e.dataTransfer.setData('application/json', JSON.stringify(template)); + e.dataTransfer.effectAllowed = 'copy'; + setDraggingTemplate(template.id); + onDragStart(template); + } + + function handleDragEnd() { + setDraggingTemplate(null); + } + + const isLoading = templateArray.length === 0; + + return ( +
    + {/* Header */} +
    +

    Widgets

    + + {/* Search input */} +
    +
    + + + +
    + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-bg-muted border border-border-light rounded-lg placeholder-text-placeholder focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-shadow" + /> + {searchQuery && ( + + )} +
    +
    + + {/* Widget list */} +
    + {/* Loading state */} + {isLoading && ( +
    +
    + + + +
    +

    Loading widgets...

    +
    + )} + + {/* Empty search results */} + {!isLoading && searchQuery && filteredTemplates.length === 0 && ( +
    +
    + + + +
    +

    No results

    +

    Try a different search term

    +
    + )} + + {/* Categories */} + {Object.entries(groupedTemplates).map(([category, categoryTemplates]) => { + const config = categoryConfig[category] || { + label: category.charAt(0).toUpperCase() + category.slice(1), + icon: ( + + + + ), + }; + const isExpanded = expandedCategories[category] ?? true; + + return ( +
    + {/* Category header */} + + + {/* Category templates */} + {isExpanded && ( +
    + {categoryTemplates.map((template) => { + const isDragging = draggingTemplate === template.id; + return ( +
    handleDragStart(e, template)} + onDragEnd={handleDragEnd} + className={` + group flex items-center gap-3 px-3 py-2.5 rounded-xl + cursor-grab active:cursor-grabbing + transition-all duration-150 + ${isDragging + ? 'bg-accent-light ring-2 ring-accent shadow-md' + : 'bg-bg-muted hover:bg-bg-page border border-transparent hover:border-border-light' + } + `} + title={template.description} + > +
    + {/* Use custom icon for custom widgets (name starts with "custom-") */} + {widgetIcons[template.name] || (template.name.startsWith('custom-') ? widgetIcons.custom : null) || ( + + + + )} +
    +
    +

    + {template.label} +

    +
    +
    + + + +
    +
    + ); + })} +
    + )} +
    + ); + })} +
    + + {/* Footer hint */} +
    +

    + Drag widgets onto the canvas +

    +
    +
    + ); +} diff --git a/frontend/src/components/screen-designer/WidgetRenderer.tsx b/frontend/src/components/screen-designer/WidgetRenderer.tsx new file mode 100644 index 0000000..54aa044 --- /dev/null +++ b/frontend/src/components/screen-designer/WidgetRenderer.tsx @@ -0,0 +1,1533 @@ +/** + * WidgetRenderer Component + * Renders a single widget based on its template type and configuration. + */ +import { useState, useEffect, useCallback } from 'react'; +import type { ScreenWidget, WidgetTemplate } from '../../types'; +import { generateQRCodeDataUrl } from '../../utils/qrcode'; +import { customWidgetService, dataSourceService, screenDesignerService } from '../../services/api'; +import { EInkImage } from '../common'; + +/** + * Map generic CSS font families to specific loaded font names + * Matches backend screen-renderer.service.ts for consistent rendering + */ +function mapFontFamily(fontFamily: string): string { + switch (fontFamily) { + case 'sans-serif': + return "'Inter', sans-serif"; + case 'monospace': + return "'Roboto Mono', monospace"; + case 'serif': + return "'Merriweather', serif"; + default: + // If already a specific font or unknown, return with fallback + return fontFamily.includes(',') ? fontFamily : `${fontFamily}, sans-serif`; + } +} + +/** + * Custom hook to fetch data from a data source + * + * This hook handles: + * - Fetching cached data from a data source by ID + * - Extracting a specific field using dot notation path + * - Loading and error states + * + * @param dataSourceId - The ID of the data source to fetch from + * @param fieldPath - The dot notation path to extract (e.g., "url" or "user.name") + */ +function useDataSourceField( + dataSourceId: number | undefined, + fieldPath: string | undefined +): { value: unknown; loading: boolean; error: string | null } { + const [value, setValue] = useState(undefined); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Extract value from nested object using dot notation path + const extractValue = useCallback((data: unknown, path: string): unknown => { + if (!data || !path) return undefined; + + const parts = path.split('.'); + let current: unknown = data; + + for (const part of parts) { + if (current === null || current === undefined) return undefined; + + // Handle array notation like "items[0]" or just "[0]" + const arrayMatch = part.match(/^(.+?)?\[(\d+)\]$/); + if (arrayMatch) { + const [, key, index] = arrayMatch; + if (key) { + current = (current as Record)[key]; + } + if (Array.isArray(current)) { + current = current[parseInt(index, 10)]; + } else { + return undefined; + } + } else { + current = (current as Record)[part]; + } + } + + return current; + }, []); + + useEffect(() => { + if (!dataSourceId || !fieldPath) { + setValue(undefined); + return; + } + + let isMounted = true; + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + const data = await dataSourceService.getCachedData(dataSourceId); + if (isMounted) { + const extracted = extractValue(data, fieldPath); + setValue(extracted); + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to fetch data'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchData(); + + return () => { + isMounted = false; + }; + }, [dataSourceId, fieldPath, extractValue]); + + return { value, loading, error }; +} + +interface WidgetRendererProps { + widget: ScreenWidget; + template: WidgetTemplate; +} + +export function WidgetRenderer({ widget, template }: WidgetRendererProps) { + const config = widget.config; + + // Render based on template type + switch (template.name) { + case 'clock': + return ; + case 'date': + return ; + case 'weather': + return ; + case 'text': + return ; + case 'qrcode': + return ; + case 'battery': + return ; + case 'daysuntil': + return ; + case 'countdown': + return ; + case 'image': + return ; + case 'divider': + return ; + case 'rectangle': + return ; + case 'wifi': + return ; + case 'deviceinfo': + return ; + case 'github': + return ; + default: + // Check if this is a custom widget (name starts with "custom-") + if (template.name.startsWith('custom-')) { + return ; + } + return ( +
    + Unknown widget: {template.name} +
    + ); + } +} + +/** + * ClockWidget - Displays current time with live updates + * + * Updates every second for reliability. The display format determines + * whether seconds are shown. + */ +function ClockWidget({ config }: { config: Record }) { + const [time, setTime] = useState(() => new Date()); + const format = (config.format as string) || '24h'; + const showSeconds = config.showSeconds as boolean; + const fontSize = (config.fontSize as number) || 48; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'monospace'); + const textAlign = (config.textAlign as string) || 'left'; + // Get configured timezone - 'local' or '' means use browser's local timezone + const configuredTimezone = (config.timezone as string) || 'local'; + const useLocalTimezone = configuredTimezone === 'local' || configuredTimezone === ''; + + // Get the browser's timezone for display when 'local' is selected + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + useEffect(() => { + // Update every second for reliable time display + const intervalId = setInterval(() => { + setTime(new Date()); + }, 1000); + + // Cleanup on unmount + return () => clearInterval(intervalId); + }, []); // Empty deps - interval runs for component lifetime + + const formatTime = () => { + // Use Intl.DateTimeFormat to properly handle timezone conversion + // When timezone is 'local', explicitly use the browser's detected timezone + // This ensures consistent behavior and the preview shows the user's actual local time + const effectiveTimezone = useLocalTimezone ? browserTimezone : configuredTimezone; + + const options: Intl.DateTimeFormatOptions = { + timeZone: effectiveTimezone, + hour: '2-digit', + minute: '2-digit', + second: showSeconds ? '2-digit' : undefined, + hour12: format === '12h', + }; + + return time.toLocaleTimeString('en-US', options); + }; + + // Map textAlign to flexbox justify-content + const justifyClass = textAlign === 'left' ? 'justify-start' : textAlign === 'right' ? 'justify-end' : 'justify-center'; + + return ( +
    + {formatTime()} +
    + ); +} + +/** + * DateWidget - Displays current date + * + * Supports configurable display of: weekday, day, month, year + * Matches backend rendering exactly for 1:1 WYSIWYG preview. + */ +function DateWidget({ config }: { config: Record }) { + const [date, setDate] = useState(new Date()); + const fontSize = (config.fontSize as number) || 24; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'sans-serif'); + const textAlign = (config.textAlign as string) || 'center'; + const locale = (config.locale as string) || 'en-US'; + + // Configurable date parts (match backend defaults) + const showWeekday = (config.showWeekday as boolean) ?? (config.showDayOfWeek as boolean) ?? false; + const showDay = (config.showDay as boolean) ?? true; + const showMonth = (config.showMonth as boolean) ?? true; + const showYear = (config.showYear as boolean) ?? true; + + // Get configured timezone - 'local' or '' means use browser's local timezone + const configuredTimezone = (config.timezone as string) || ''; + const useLocalTimezone = configuredTimezone === 'local' || configuredTimezone === ''; + + // Get the browser's timezone for display when 'local' is selected + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + useEffect(() => { + // Update date at midnight in the configured timezone + const effectiveTimezone = useLocalTimezone ? browserTimezone : configuredTimezone; + + // Calculate midnight in the target timezone + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: effectiveTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + // Get current time in target timezone + const parts = formatter.formatToParts(now); + const getPart = (type: string) => parts.find(p => p.type === type)?.value || '0'; + const hours = parseInt(getPart('hour'), 10); + const minutes = parseInt(getPart('minute'), 10); + const seconds = parseInt(getPart('second'), 10); + + // Calculate seconds until midnight in target timezone + const secondsUntilMidnight = (24 - hours - 1) * 3600 + (60 - minutes - 1) * 60 + (60 - seconds); + const msUntilMidnight = secondsUntilMidnight * 1000; + + const timeout = setTimeout(() => { + setDate(new Date()); + }, msUntilMidnight + 1000); // Add 1 second buffer to ensure we're past midnight + + return () => clearTimeout(timeout); + }, [date, useLocalTimezone, browserTimezone, configuredTimezone]); + + const formatDate = () => { + // Use explicit timezone (matches backend exactly) + const effectiveTimezone = useLocalTimezone ? browserTimezone : configuredTimezone; + + // Build format options based on what should be shown (matches backend exactly) + const options: Intl.DateTimeFormatOptions = { + timeZone: effectiveTimezone, + }; + + if (showWeekday) { + options.weekday = 'long'; + } + if (showDay) { + options.day = 'numeric'; + } + if (showMonth) { + options.month = 'long'; + } + if (showYear) { + options.year = 'numeric'; + } + + // Fallback if nothing is selected + if (!showWeekday && !showDay && !showMonth && !showYear) { + options.day = 'numeric'; + options.month = 'long'; + options.year = 'numeric'; + } + + return date.toLocaleDateString(locale, options); + }; + + // Match backend SVG rendering exactly: + // - lineHeight = fontSize * 1.2 + // - Text centered both horizontally and vertically + const lineHeight = fontSize * 1.2; + + // Map textAlign to flexbox justify-content + const justifyContent = textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'; + + return ( +
    + {formatDate()} +
    + ); +} + +/** + * Weather code to condition mapping (WMO codes) + */ +const weatherConditions: Record = { + 0: { text: 'Clear', icon: 'sun' }, + 1: { text: 'Mostly Clear', icon: 'sun' }, + 2: { text: 'Partly Cloudy', icon: 'cloud-sun' }, + 3: { text: 'Cloudy', icon: 'cloud' }, + 45: { text: 'Foggy', icon: 'fog' }, + 48: { text: 'Icy Fog', icon: 'fog' }, + 51: { text: 'Light Drizzle', icon: 'rain' }, + 53: { text: 'Drizzle', icon: 'rain' }, + 55: { text: 'Heavy Drizzle', icon: 'rain' }, + 61: { text: 'Light Rain', icon: 'rain' }, + 63: { text: 'Rain', icon: 'rain' }, + 65: { text: 'Heavy Rain', icon: 'rain' }, + 71: { text: 'Light Snow', icon: 'snow' }, + 73: { text: 'Snow', icon: 'snow' }, + 75: { text: 'Heavy Snow', icon: 'snow' }, + 80: { text: 'Light Showers', icon: 'rain' }, + 81: { text: 'Showers', icon: 'rain' }, + 82: { text: 'Heavy Showers', icon: 'rain' }, + 95: { text: 'Thunderstorm', icon: 'thunder' }, + 96: { text: 'Thunderstorm', icon: 'thunder' }, + 99: { text: 'Heavy Storm', icon: 'thunder' }, +}; + +/** + * Weather icon components + */ +function WeatherIcon({ icon, size = 48 }: { icon: string; size?: number }) { + const iconClass = `text-text-primary`; + + switch (icon) { + case 'sun': + return ( + + + {[0, 45, 90, 135, 180, 225, 270, 315].map(angle => { + const rad = (angle * Math.PI) / 180; + const x1 = 12 + Math.cos(rad) * 7; + const y1 = 12 + Math.sin(rad) * 7; + const x2 = 12 + Math.cos(rad) * 10; + const y2 = 12 + Math.sin(rad) * 10; + return ; + })} + + ); + case 'cloud': + return ( + + + + + + ); + case 'cloud-sun': + return ( + + + {[0, 60, 120, 180, 240, 300].map(angle => { + const rad = (angle * Math.PI) / 180; + const x1 = 16 + Math.cos(rad) * 4.5; + const y1 = 6 + Math.sin(rad) * 4.5; + const x2 = 16 + Math.cos(rad) * 6; + const y2 = 6 + Math.sin(rad) * 6; + return ; + })} + + + + + ); + case 'rain': + return ( + + + + + + + + + ); + case 'snow': + return ( + + + + + * + * + * + + ); + case 'thunder': + return ( + + + + + + + ); + case 'fog': + return ( + + + + + + ); + default: + return ( + + + + ); + } +} + +/** + * WeatherWidget - Displays weather preview with real data from Open-Meteo API + */ +function WeatherWidget({ config }: { config: Record }) { + const [weatherData, setWeatherData] = useState<{ + temperature: number; + weatherCode: number; + humidity: number; + windSpeed: number; + dayName: string; + } | null>(null); + const [loading, setLoading] = useState(true); + + const location = (config.location as string) || 'Unknown'; + const latitude = (config.latitude as number) || 52.2297; + const longitude = (config.longitude as number) || 21.0122; + const units = (config.units as string) || 'metric'; + const forecastDay = (config.forecastDay as number) || 0; + const forecastTime = (config.forecastTime as string) || 'current'; + const showIcon = config.showIcon !== false; + const showTemperature = config.showTemperature !== false; + const showCondition = config.showCondition !== false; + const showLocation = config.showLocation !== false; + const showHumidity = config.showHumidity as boolean; + const showWind = config.showWind as boolean; + const showDayName = config.showDayName as boolean; + const fontSize = (config.fontSize as number) || 32; + + // Fetch weather data + useEffect(() => { + let isMounted = true; + + const fetchWeather = async () => { + setLoading(true); + try { + const url = new URL('https://api.open-meteo.com/v1/forecast'); + url.searchParams.set('latitude', latitude.toString()); + url.searchParams.set('longitude', longitude.toString()); + url.searchParams.set('current', 'temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m'); + url.searchParams.set('hourly', 'temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m'); + url.searchParams.set('forecast_days', Math.max(forecastDay + 1, 1).toString()); + url.searchParams.set('timezone', 'auto'); + + const response = await fetch(url.toString()); + const data = await response.json(); + + if (!isMounted) return; + + const now = new Date(); + const targetDate = new Date(now); + targetDate.setDate(targetDate.getDate() + forecastDay); + + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + let dayName = dayNames[targetDate.getDay()]; + if (forecastDay === 0) dayName = 'Today'; + else if (forecastDay === 1) dayName = 'Tomorrow'; + + if (forecastDay === 0 && forecastTime === 'current') { + setWeatherData({ + temperature: Math.round(data.current.temperature_2m), + weatherCode: data.current.weather_code, + humidity: data.current.relative_humidity_2m, + windSpeed: Math.round(data.current.wind_speed_10m), + dayName, + }); + } else { + const hourMap: Record = { + 'current': now.getHours(), + 'morning': 8, + 'noon': 12, + 'afternoon': 15, + 'evening': 19, + 'night': 22, + }; + const targetHour = hourMap[forecastTime] ?? 12; + + const year = targetDate.getFullYear(); + const month = String(targetDate.getMonth() + 1).padStart(2, '0'); + const day = String(targetDate.getDate()).padStart(2, '0'); + const hour = String(targetHour).padStart(2, '0'); + const targetTimeStr = `${year}-${month}-${day}T${hour}:00`; + + const hourlyTimes = data.hourly?.time || []; + const index = hourlyTimes.findIndex((t: string) => t === targetTimeStr); + + if (index >= 0 && data.hourly) { + setWeatherData({ + temperature: Math.round(data.hourly.temperature_2m[index]), + weatherCode: data.hourly.weather_code[index], + humidity: data.hourly.relative_humidity_2m[index], + windSpeed: Math.round(data.hourly.wind_speed_10m[index]), + dayName, + }); + } else { + setWeatherData({ + temperature: Math.round(data.current.temperature_2m), + weatherCode: data.current.weather_code, + humidity: data.current.relative_humidity_2m, + windSpeed: Math.round(data.current.wind_speed_10m), + dayName, + }); + } + } + } catch (error) { + console.error('Failed to fetch weather:', error); + if (isMounted) setWeatherData(null); + } finally { + if (isMounted) setLoading(false); + } + }; + + fetchWeather(); + + return () => { + isMounted = false; + }; + }, [latitude, longitude, forecastDay, forecastTime]); + + if (loading) { + return ( +
    + + + + +
    + ); + } + + if (!weatherData) { + return ( +
    + {location} + Weather unavailable +
    + ); + } + + const condition = weatherConditions[weatherData.weatherCode] || { text: 'Unknown', icon: 'cloud' as const }; + const displayTemp = units === 'imperial' + ? Math.round((weatherData.temperature * 9/5) + 32) + : weatherData.temperature; + const tempUnit = units === 'imperial' ? '°F' : '°C'; + const windUnit = units === 'imperial' ? 'mph' : 'km/h'; + const displayWind = units === 'imperial' ? Math.round(weatherData.windSpeed * 0.621) : weatherData.windSpeed; + + const iconSize = Math.min(fontSize * 1.5, 48); + const smallFontSize = Math.max(10, fontSize * 0.4); + + return ( +
    + {showDayName && forecastDay > 0 && ( +
    + {weatherData.dayName} +
    + )} +
    + {showIcon && } +
    + {showTemperature && ( +
    + {displayTemp}{tempUnit} +
    + )} + {showCondition && ( +
    + {condition.text} +
    + )} +
    +
    + {showHumidity && ( +
    + Humidity: {weatherData.humidity}% +
    + )} + {showWind && ( +
    + Wind: {displayWind} {windUnit} +
    + )} + {showLocation && ( +
    + {location} +
    + )} +
    + ); +} + +/** + * TextWidget - Displays custom text or dynamic text from a data source + * + * Supports two modes: + * 1. Static text: Uses config.text directly + * 2. Dynamic text: Fetches from data source using config.dataSourceId and config.dataSourceField + * + * Text rendering matches the backend SVG renderer behavior: + * - Word wrapping based on container width + * - Overflow hidden to show exactly what device will display + */ +function TextWidget({ config }: { config: Record }) { + const staticText = (config.text as string) || 'Text'; + const fontSize = (config.fontSize as number) || 24; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'sans-serif'); + const fontWeight = (config.fontWeight as string) || 'normal'; + const textAlign = (config.textAlign as string) || 'left'; + const color = (config.color as string) || '#000000'; + + // Data source configuration + const dataSourceId = config.dataSourceId as number | undefined; + const dataSourceField = config.dataSourceField as string | undefined; + + // Fetch data from data source if configured + const { value: dynamicValue, loading, error } = useDataSourceField(dataSourceId, dataSourceField); + + // Use dynamic value if available, otherwise fall back to static text + const displayText = dataSourceId && dataSourceField + ? (loading ? 'Loading...' : error ? 'Error' : String(dynamicValue ?? staticText)) + : staticText; + + return ( +
    +
    + {displayText} +
    +
    + ); +} + +/** + * QRCodeWidget - Displays a QR code generated locally (no external API) + */ +function QRCodeWidget({ config }: { config: Record }) { + const content = (config.content as string) || 'https://example.com'; + const size = (config.size as number) || 100; + const [qrDataUrl, setQrDataUrl] = useState(null); + const [imageError, setImageError] = useState(false); + + // Generate QR code locally as data URL + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setImageError(false); + generateQRCodeDataUrl(content, size) + .then((dataUrl) => { + if (!cancelled) setQrDataUrl(dataUrl); + }) + .catch(() => { + if (!cancelled) setImageError(true); + }); + return () => { cancelled = true; }; + }, [content, size]); + + return ( +
    +
    + {!imageError && qrDataUrl ? ( + {`QR setImageError(true)} + /> + ) : imageError ? ( + // Fallback SVG if generation fails + + + + + + + ) : null} +
    +
    + {content.length > 30 ? `${content.substring(0, 30)}...` : content} +
    +
    + ); +} + +/** + * BatteryWidget - Displays battery level preview + */ +function BatteryWidget({ config }: { config: Record }) { + const showPercentage = config.showPercentage as boolean; + const showIcon = config.showIcon as boolean; + const fontSize = (config.fontSize as number) || 16; + + // Preview value + const batteryLevel = 85; + + return ( +
    + {showIcon && ( + + + + + + )} + {showPercentage && {batteryLevel}%} +
    + ); +} + +/** + * DaysUntilWidget - Displays days remaining until an event + * Updates once per day at midnight. + * Fully customizable with prefix/suffix text. + */ +function DaysUntilWidget({ config }: { config: Record }) { + const [now, setNow] = useState(new Date()); + const targetDateStr = (config.targetDate as string) || '2025-12-25'; + const fontSize = (config.fontSize as number) || 32; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'sans-serif'); + const color = (config.color as string) || '#000000'; + + // New customizable prefix/suffix format + const labelPrefix = (config.labelPrefix as string) ?? 'Days till Christmas: '; + const labelSuffix = (config.labelSuffix as string) || ''; + + useEffect(() => { + // Update at midnight + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + const msUntilMidnight = tomorrow.getTime() - now.getTime(); + + const timeout = setTimeout(() => { + setNow(new Date()); + }, msUntilMidnight); + + return () => clearTimeout(timeout); + }, [now]); + + // Calculate days until target date + const targetDate = new Date(targetDateStr); + targetDate.setHours(0, 0, 0, 0); + const today = new Date(now); + today.setHours(0, 0, 0, 0); + + const diffTime = targetDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Format the display text using prefix + number + suffix + const formatText = () => { + const days = diffDays < 0 ? Math.abs(diffDays) : diffDays; + return `${labelPrefix}${days}${labelSuffix}`; + }; + + return ( +
    + {formatText()} +
    + ); +} + +/** + * CountdownWidget - Displays countdown timer to a specific date/time + */ +function CountdownWidget({ config }: { config: Record }) { + const [now, setNow] = useState(new Date()); + const targetDateStr = (config.targetDate as string) || '2025-12-31T23:59:59'; + const label = (config.label as string) || ''; + const fontSize = (config.fontSize as number) || 32; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'monospace'); + const showDays = config.showDays !== false; + const showHours = config.showHours !== false; + const showMinutes = config.showMinutes !== false; + const showSeconds = config.showSeconds !== false; + + useEffect(() => { + const interval = setInterval(() => { + setNow(new Date()); + }, 1000); + return () => clearInterval(interval); + }, []); + + const targetDate = new Date(targetDateStr); + const diffMs = targetDate.getTime() - now.getTime(); + + if (diffMs <= 0) { + return ( +
    + {label || 'Time\'s up!'} +
    + ); + } + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diffMs % (1000 * 60)) / 1000); + + const parts: string[] = []; + if (showDays && days > 0) parts.push(`${days}d`); + if (showHours) parts.push(`${hours.toString().padStart(2, '0')}h`); + if (showMinutes) parts.push(`${minutes.toString().padStart(2, '0')}m`); + if (showSeconds) parts.push(`${seconds.toString().padStart(2, '0')}s`); + + return ( +
    + {label &&
    {label}
    } +
    {parts.join(' ')}
    +
    + ); +} + +/** + * ImageWidget - Displays a static image from URL or dynamic image from data source + * + * Supports two modes: + * 1. Static URL: Uses config.url directly + * 2. Dynamic URL: Fetches URL from data source using config.dataSourceId and config.dataSourceField + * + * Shows image in grayscale to simulate e-ink display appearance + */ +function ImageWidget({ config }: { config: Record }) { + const staticUrl = (config.url as string) || (config.imageUrl as string) || ''; + const fit = (config.fit as string) || 'contain'; + + // Data source configuration + const dataSourceId = config.dataSourceId as number | undefined; + const dataSourceField = config.dataSourceField as string | undefined; + + // Fetch data from data source if configured + const { value: dynamicValue, loading, error } = useDataSourceField(dataSourceId, dataSourceField); + + // Use dynamic URL if available, otherwise fall back to static URL + const url = dataSourceId && dataSourceField + ? (String(dynamicValue || '') || staticUrl) + : staticUrl; + + // Show loading state + if (loading) { + return ( +
    + + + + + Loading... +
    + ); + } + + // Show error state + if (error) { + return ( +
    + + + + Error loading +
    + ); + } + + if (!url) { + return ( +
    + + + + No image URL +
    + ); + } + + // Use EInkImage component for proper e-ink rendering and GIF freezing + return ( +
    + +
    + ); +} + +/** + * DividerWidget - Displays a horizontal or vertical divider line + */ +function DividerWidget({ config }: { config: Record }) { + const orientation = (config.orientation as string) || 'horizontal'; + const thickness = (config.thickness as number) || 2; + const color = (config.color as string) || '#000000'; + const style = (config.style as string) || 'solid'; + + return ( +
    +
    +
    + ); +} + +/** + * RectangleWidget - Displays a rectangle shape + */ +function RectangleWidget({ config }: { config: Record }) { + const backgroundColor = (config.backgroundColor as string) || '#000000'; + const borderColor = (config.borderColor as string) || '#000000'; + const borderWidth = (config.borderWidth as number) || 0; + + return ( +
    0 ? `${borderWidth}px solid ${borderColor}` : 'none', + }} + /> + ); +} + +/** + * WifiWidget - Displays WiFi signal strength + */ +function WifiWidget({ config }: { config: Record }) { + const showStrength = config.showStrength !== false; + const showIcon = config.showIcon !== false; + const fontSize = (config.fontSize as number) || 16; + + // Preview value (actual value comes from device at render time) + const signalStrength = -55; // dBm + + return ( +
    + {showIcon && ( + + + + )} + {showStrength && {signalStrength} dBm} +
    + ); +} + +/** + * DeviceInfoWidget - Displays device information + */ +function DeviceInfoWidget({ config }: { config: Record }) { + const showName = config.showName !== false; + const showFirmware = config.showFirmware !== false; + const showMac = config.showMac as boolean; + const fontSize = (config.fontSize as number) || 14; + + // Preview values + const deviceName = 'TRMNL Device'; + const firmware = 'v1.0.0'; + const mac = 'AA:BB:CC:DD:EE:FF'; + + return ( +
    + {showName &&
    {deviceName}
    } + {showFirmware &&
    Firmware: {firmware}
    } + {showMac &&
    {mac}
    } +
    + ); +} + +/** + * GitHubWidget - Displays GitHub repository star count + * Uses backend proxy to fetch data (which has the configured token) + */ +function GitHubWidget({ config }: { config: Record }) { + const [stars, setStars] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const owner = (config.owner as string) || 'facebook'; + const repo = (config.repo as string) || 'react'; + const showIcon = config.showIcon !== false; + const showRepoName = config.showRepoName as boolean; + const fontSize = (config.fontSize as number) || 32; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'sans-serif'); + + useEffect(() => { + let isMounted = true; + + const fetchStars = async () => { + if (!owner || !repo) { + setError('Missing repo info'); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + // Use backend proxy which has the GitHub token configured + const result = await screenDesignerService.getGitHubStars(owner, repo); + + if (isMounted) { + if (result) { + setStars(result.stars); + } else { + setError('Failed to fetch'); + } + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to fetch'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchStars(); + + return () => { + isMounted = false; + }; + }, [owner, repo]); + + // Format star count (e.g., 1.2k, 15.3k) + const formatStars = (count: number): string => { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } else if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k`; + } + return String(count); + }; + + if (loading) { + return ( +
    + + + + +
    + ); + } + + if (error) { + return ( +
    + {owner}/{repo} + Rate limited - try later +
    + ); + } + + const iconSize = Math.min(fontSize * 1.2, 48); + const smallFontSize = Math.max(12, fontSize * 0.4); + + return ( +
    +
    + {showIcon && ( + + + + )} + + {formatStars(stars || 0)} + +
    + {showRepoName && ( +
    + {owner}/{repo} +
    + )} +
    + ); +} + +/** + * GridContentRenderer - Renders grid content for custom widgets + * Displays multiple values in a CSS grid layout + */ +interface GridCell { + row: number; + col: number; + field: string; + fieldType: string; + label?: string; + prefix?: string; + suffix?: string; + value: unknown; + formattedValue: string; + align?: 'left' | 'center' | 'right'; + verticalAlign?: 'top' | 'middle' | 'bottom'; +} + +interface GridContent { + type: 'grid'; + gridCols: number; + gridRows: number; + gridGap: number; + cells: GridCell[]; +} + +interface GridCellOverride { + fontSize?: number; + fontWeight?: 'normal' | 'bold'; + fontFamily?: string; + align?: 'left' | 'center' | 'right'; + verticalAlign?: 'top' | 'middle' | 'bottom'; + imageFit?: 'contain' | 'cover' | 'fill'; +} + +function GridContentRenderer({ + content, + fontSize, + fontFamily: rawFontFamily = 'sans-serif', + cellOverrides = {}, +}: { + content: GridContent; + fontSize: number; + fontFamily?: string; + cellOverrides?: Record; +}) { + const fontFamily = mapFontFamily(rawFontFamily); + const { gridCols, gridRows, gridGap, cells } = content; + + // Create a map of cells by position for quick lookup + const cellMap = new Map(); + for (const cell of cells) { + cellMap.set(`${cell.row}-${cell.col}`, cell); + } + + return ( +
    + {Array.from({ length: gridRows * gridCols }).map((_, index) => { + const row = Math.floor(index / gridCols); + const col = index % gridCols; + const cellKey = `${row}-${col}`; + const cell = cellMap.get(cellKey); + + if (!cell) { + return
    ; + } + + // Get cell-specific overrides + const override = cellOverrides[cellKey] || {}; + const baseCellFontSize = fontSize * 0.7; + const cellFontSize = override.fontSize || baseCellFontSize; + const cellFontWeight = override.fontWeight || 'bold'; + // Apply font mapping to override or use already-mapped parent fontFamily + const cellFontFamily = override.fontFamily ? mapFontFamily(override.fontFamily) : fontFamily; + const cellAlign = override.align || cell.align || 'center'; + const cellVerticalAlign = override.verticalAlign || cell.verticalAlign || 'middle'; + const imageFit = override.imageFit || 'contain'; + + // Horizontal alignment (affects text-align and flex items alignment) + const alignItems = cellAlign === 'left' ? 'items-start' : cellAlign === 'right' ? 'items-end' : 'items-center'; + const textAlignClass = cellAlign === 'left' ? 'text-left' : cellAlign === 'right' ? 'text-right' : 'text-center'; + const justifyContent = cellAlign === 'left' ? 'justify-start' : cellAlign === 'right' ? 'justify-end' : 'justify-center'; + + // Vertical alignment (affects justify-content in flex column) + const verticalJustify = cellVerticalAlign === 'top' ? 'justify-start' : cellVerticalAlign === 'bottom' ? 'justify-end' : 'justify-center'; + + return ( +
    + {cell.label && ( +
    + {cell.label} +
    + )} +
    + {cell.fieldType === 'image' && cell.value ? ( + + ) : ( + + {cell.formattedValue} + + )} +
    +
    + ); + })} +
    + ); +} + +/** + * CustomWidgetRenderer - Renders a custom widget with data from its data source + * Fetches preview data from the API and displays based on display type + * + * Supports multiple display types: + * - 'value': Single value display (text or image based on fieldType) + * - 'title-value': Title with value (value can be image based on valueFieldType) + * - 'list': List of items + * - 'template': Custom template string interpolation + * - 'grid': Multiple values in a grid layout + * + * Font settings (fontSize, fontFamily, fontWeight, textAlign, color) are + * configured in the screen designer and passed via config. + */ +function CustomWidgetRenderer({ config }: { config: Record }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [renderedContent, setRenderedContent] = useState | null>(null); + const [widgetConfig, setWidgetConfig] = useState>({}); + + const customWidgetId = config.customWidgetId as number; + + // Font settings from screen designer config + const fontSize = (config.fontSize as number) || 24; + const fontFamily = mapFontFamily((config.fontFamily as string) || 'sans-serif'); + const fontWeight = (config.fontWeight as string) || 'normal'; + const textAlign = (config.textAlign as string) || 'center'; + const verticalAlign = (config.verticalAlign as string) || 'middle'; + const color = (config.color as string) || '#000000'; + + // Per-cell overrides from screen designer + const cellOverrides = (config.cellOverrides as Record) || {}; + + // Compute alignment classes + const horizontalAlignClass = textAlign === 'left' ? 'items-start' : textAlign === 'right' ? 'items-end' : 'items-center'; + const verticalAlignClass = verticalAlign === 'top' ? 'justify-start' : verticalAlign === 'bottom' ? 'justify-end' : 'justify-center'; + + useEffect(() => { + let isMounted = true; + + if (!customWidgetId) { + setError('No widget ID'); + setLoading(false); + return; + } + + const fetchPreview = async () => { + try { + setLoading(true); + const preview = await customWidgetService.getPreview(customWidgetId); + if (!isMounted) return; + setRenderedContent(preview.renderedContent); + // Store the widget's config so we know fieldType, valueFieldType, etc. + setWidgetConfig((preview.widget?.config as Record) || {}); + setError(null); + } catch (err) { + if (isMounted) setError(err instanceof Error ? err.message : 'Failed to load'); + } finally { + if (isMounted) setLoading(false); + } + }; + + fetchPreview(); + + return () => { + isMounted = false; + }; + }, [customWidgetId]); + + if (loading) { + return ( +
    +
    Loading...
    +
    + ); + } + + if (error) { + return ( +
    + {error} +
    + ); + } + + if (!renderedContent) { + return ( +
    + No data +
    + ); + } + + // Check if field should be rendered as image + const fieldType = widgetConfig.fieldType as string | undefined; + const valueFieldType = widgetConfig.valueFieldType as string | undefined; + + // Render based on content type and field configuration + // Uses EInkImage component for proper e-ink rendering and GIF freezing + // Font settings are applied from config for consistent appearance + const baseStyle: React.CSSProperties = { + fontSize: `${fontSize}px`, + fontFamily, + fontWeight, + textAlign: textAlign as React.CSSProperties['textAlign'], + color, + lineHeight: 1.2, + }; + + return ( +
    + {typeof renderedContent === 'string' ? ( + // Single value - check if it should be rendered as image + fieldType === 'image' ? ( + + ) : ( +
    + {renderedContent} +
    + ) + ) : Array.isArray(renderedContent) ? ( + // List display - items already have prefix (•, -, 1.) from renderList(), so no list-style needed +
      + {renderedContent.map((item, i) => ( +
    • + {item} +
    • + ))} +
    + ) : typeof renderedContent === 'object' && renderedContent !== null ? ( + // Object display - could be grid, title-value, or other + 'type' in renderedContent && renderedContent.type === 'grid' ? ( + // Grid display + + ) : (('title' in renderedContent || 'label' in renderedContent) && 'value' in renderedContent) ? ( + // Title-value display +
    +
    + {String('title' in renderedContent ? renderedContent.title : renderedContent.label)} +
    + {valueFieldType === 'image' ? ( +
    + +
    + ) : ( +
    {String(renderedContent.value)}
    + )} +
    + ) : ( +
    +            {JSON.stringify(renderedContent, null, 2)}
    +          
    + ) + ) : ( + Invalid content + )} +
    + ); +} diff --git a/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx b/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx new file mode 100644 index 0000000..85d298c --- /dev/null +++ b/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx @@ -0,0 +1,2394 @@ +/** + * WidgetSettingsPanel Component + * Panel for configuring the selected widget's settings. + */ +import { useCallback, useState, useRef, useEffect } from 'react'; +import type { ScreenWidget, WidgetTemplate, DataSource, FieldMeta, CustomWidget, GridCellOverride } from '../../types'; +import { screenDesignerService, dataSourceService, customWidgetService } from '../../services/api'; +import { config } from '../../config'; +import { SearchableSelect } from '../common/SearchableSelect'; + +interface WidgetSettingsPanelProps { + widget: ScreenWidget | null; + template: WidgetTemplate | null; + allWidgets?: ScreenWidget[]; + templates?: WidgetTemplate[]; + onUpdateWidget: (updates: Partial) => void; + onDeleteWidget: () => void; + onSelectWidget?: (id: number) => void; +} + +// Get the browser's detected timezone +const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Timezone data with city aliases for smart search +// Each timezone has: value (IANA), label (display), offset, region, and searchable city aliases +interface TimezoneData { + value: string; + label: string; + offset: string; + region: string; + cities: string[]; // Searchable city names that map to this timezone +} + +const timezoneData: TimezoneData[] = [ + // UTC + { value: 'UTC', label: 'UTC', offset: '±0:00', region: 'UTC', cities: ['utc', 'gmt', 'greenwich', 'coordinated universal time'] }, + + // Americas (West to East) + { value: 'Pacific/Honolulu', label: 'Hawaii', offset: '-10:00', region: 'Americas', cities: ['honolulu', 'hawaii', 'maui', 'oahu'] }, + { value: 'America/Anchorage', label: 'Alaska', offset: '-9:00', region: 'Americas', cities: ['anchorage', 'alaska', 'juneau', 'fairbanks'] }, + { value: 'America/Los_Angeles', label: 'Los Angeles', offset: '-8:00', region: 'Americas', cities: ['los angeles', 'la', 'san francisco', 'seattle', 'portland', 'san diego', 'las vegas', 'pacific', 'hollywood', 'sacramento', 'oakland'] }, + { value: 'America/Denver', label: 'Denver', offset: '-7:00', region: 'Americas', cities: ['denver', 'phoenix', 'salt lake', 'arizona', 'mountain', 'albuquerque', 'colorado', 'utah'] }, + { value: 'America/Chicago', label: 'Chicago', offset: '-6:00', region: 'Americas', cities: ['chicago', 'houston', 'dallas', 'austin', 'minneapolis', 'central', 'mexico city', 'guadalajara', 'san antonio', 'kansas city', 'milwaukee', 'oklahoma'] }, + { value: 'America/New_York', label: 'New York', offset: '-5:00', region: 'Americas', cities: ['new york', 'nyc', 'boston', 'miami', 'atlanta', 'philadelphia', 'washington', 'detroit', 'toronto', 'montreal', 'eastern', 'manhattan', 'brooklyn', 'baltimore', 'pittsburgh', 'charlotte', 'tampa', 'orlando', 'quebec'] }, + { value: 'America/Sao_Paulo', label: 'São Paulo', offset: '-3:00', region: 'Americas', cities: ['sao paulo', 'rio', 'rio de janeiro', 'brasilia', 'brazil', 'buenos aires', 'argentina', 'salvador', 'fortaleza', 'belo horizonte'] }, + + // Western Europe + { value: 'Europe/London', label: 'London', offset: '+0:00', region: 'Western Europe', cities: ['london', 'uk', 'united kingdom', 'britain', 'england', 'dublin', 'ireland', 'lisbon', 'portugal', 'edinburgh', 'manchester', 'birmingham', 'glasgow', 'liverpool', 'bristol', 'cardiff', 'belfast', 'leeds', 'sheffield', 'porto', 'cork'] }, + { value: 'Europe/Paris', label: 'Paris', offset: '+1:00', region: 'Western Europe', cities: ['paris', 'france', 'lyon', 'marseille', 'nice', 'monaco', 'toulouse', 'bordeaux', 'lille', 'nantes', 'strasbourg', 'montpellier'] }, + { value: 'Europe/Amsterdam', label: 'Amsterdam', offset: '+1:00', region: 'Western Europe', cities: ['amsterdam', 'netherlands', 'holland', 'rotterdam', 'hague', 'brussels', 'belgium', 'antwerp', 'utrecht', 'eindhoven', 'ghent', 'bruges', 'liege', 'luxembourg'] }, + { value: 'Europe/Madrid', label: 'Madrid', offset: '+1:00', region: 'Western Europe', cities: ['madrid', 'spain', 'barcelona', 'valencia', 'seville', 'malaga', 'bilbao', 'zaragoza', 'palma', 'canary islands', 'tenerife', 'mallorca', 'ibiza'] }, + + // Central Europe + { value: 'Europe/Berlin', label: 'Berlin', offset: '+1:00', region: 'Central Europe', cities: ['berlin', 'germany', 'munich', 'frankfurt', 'hamburg', 'cologne', 'dusseldorf', 'stuttgart', 'dortmund', 'essen', 'bremen', 'leipzig', 'dresden', 'hannover', 'nuremberg', 'bonn'] }, + { value: 'Europe/Rome', label: 'Rome', offset: '+1:00', region: 'Central Europe', cities: ['rome', 'italy', 'milan', 'florence', 'venice', 'naples', 'turin', 'vatican', 'bologna', 'genoa', 'palermo', 'verona', 'pisa', 'siena'] }, + { value: 'Europe/Vienna', label: 'Vienna', offset: '+1:00', region: 'Central Europe', cities: ['vienna', 'austria', 'salzburg', 'innsbruck', 'graz', 'linz'] }, + { value: 'Europe/Zurich', label: 'Zurich', offset: '+1:00', region: 'Central Europe', cities: ['zurich', 'zürich', 'switzerland', 'geneva', 'bern', 'basel', 'lausanne', 'lucerne', 'interlaken', 'davos'] }, + { value: 'Europe/Warsaw', label: 'Warsaw', offset: '+1:00', region: 'Central Europe', cities: ['warsaw', 'warszawa', 'poland', 'polska', 'krakow', 'kraków', 'cracow', 'lodz', 'łódź', 'wroclaw', 'wrocław', 'breslau', 'poznan', 'poznań', 'gdansk', 'gdańsk', 'danzig', 'szczecin', 'stettin', 'lublin', 'katowice', 'bialystok', 'białystok', 'czestochowa', 'częstochowa', 'radom', 'torun', 'toruń', 'kielce', 'rzeszow', 'rzeszów', 'olsztyn', 'bydgoszcz', 'gliwice', 'zabrze', 'bytom', 'opole', 'zielona gora', 'zielona góra', 'sopot', 'gdynia', 'zakopane', 'plock', 'płock', 'legnica', 'elblag', 'elbląg', 'tarnow', 'tarnów', 'gorzow', 'gorzów'] }, + { value: 'Europe/Prague', label: 'Prague', offset: '+1:00', region: 'Central Europe', cities: ['prague', 'praha', 'czech', 'czechia', 'brno', 'ostrava', 'bratislava', 'slovakia', 'kosice', 'pilsen', 'olomouc', 'liberec'] }, + { value: 'Europe/Budapest', label: 'Budapest', offset: '+1:00', region: 'Central Europe', cities: ['budapest', 'hungary', 'debrecen', 'szeged', 'pecs', 'gyor'] }, + + // Northern Europe + { value: 'Europe/Stockholm', label: 'Stockholm', offset: '+1:00', region: 'Northern Europe', cities: ['stockholm', 'sweden', 'gothenburg', 'malmo', 'goteborg', 'uppsala'] }, + { value: 'Europe/Oslo', label: 'Oslo', offset: '+1:00', region: 'Northern Europe', cities: ['oslo', 'norway', 'bergen', 'trondheim', 'stavanger', 'tromso'] }, + { value: 'Europe/Copenhagen', label: 'Copenhagen', offset: '+1:00', region: 'Northern Europe', cities: ['copenhagen', 'denmark', 'aarhus', 'odense', 'aalborg'] }, + { value: 'Europe/Helsinki', label: 'Helsinki', offset: '+2:00', region: 'Northern Europe', cities: ['helsinki', 'finland', 'tampere', 'turku', 'oulu', 'espoo', 'vantaa'] }, + + // Eastern Europe + { value: 'Europe/Athens', label: 'Athens', offset: '+2:00', region: 'Eastern Europe', cities: ['athens', 'greece', 'thessaloniki', 'santorini', 'crete', 'mykonos', 'rhodes', 'corfu', 'patras'] }, + { value: 'Europe/Bucharest', label: 'Bucharest', offset: '+2:00', region: 'Eastern Europe', cities: ['bucharest', 'romania', 'cluj', 'timisoara', 'iasi', 'constanta', 'brasov', 'sibiu'] }, + { value: 'Europe/Sofia', label: 'Sofia', offset: '+2:00', region: 'Eastern Europe', cities: ['sofia', 'bulgaria', 'plovdiv', 'varna', 'burgas'] }, + { value: 'Europe/Tallinn', label: 'Tallinn', offset: '+2:00', region: 'Eastern Europe', cities: ['tallinn', 'estonia', 'tartu'] }, + { value: 'Europe/Riga', label: 'Riga', offset: '+2:00', region: 'Eastern Europe', cities: ['riga', 'latvia', 'jurmala'] }, + { value: 'Europe/Vilnius', label: 'Vilnius', offset: '+2:00', region: 'Eastern Europe', cities: ['vilnius', 'lithuania', 'kaunas', 'klaipeda'] }, + { value: 'Europe/Kyiv', label: 'Kyiv', offset: '+2:00', region: 'Eastern Europe', cities: ['kyiv', 'kiev', 'ukraine', 'kharkiv', 'odessa', 'lviv', 'dnipro', 'donetsk', 'zaporizhzhia'] }, + { value: 'Europe/Istanbul', label: 'Istanbul', offset: '+3:00', region: 'Eastern Europe', cities: ['istanbul', 'turkey', 'ankara', 'izmir', 'antalya', 'bursa', 'adana', 'bodrum', 'cappadocia'] }, + { value: 'Europe/Moscow', label: 'Moscow', offset: '+3:00', region: 'Eastern Europe', cities: ['moscow', 'russia', 'st petersburg', 'saint petersburg', 'nizhny novgorod', 'yekaterinburg', 'novosibirsk', 'kazan', 'samara', 'sochi'] }, + + // Middle East & Africa + { value: 'Africa/Cairo', label: 'Cairo', offset: '+2:00', region: 'Middle East & Africa', cities: ['cairo', 'egypt', 'alexandria', 'giza', 'luxor', 'aswan', 'sharm el sheikh', 'hurghada'] }, + { value: 'Africa/Johannesburg', label: 'Johannesburg', offset: '+2:00', region: 'Middle East & Africa', cities: ['johannesburg', 'south africa', 'cape town', 'durban', 'pretoria', 'port elizabeth', 'soweto'] }, + { value: 'Asia/Jerusalem', label: 'Jerusalem', offset: '+2:00', region: 'Middle East & Africa', cities: ['jerusalem', 'israel', 'tel aviv', 'haifa', 'eilat', 'nazareth'] }, + { value: 'Asia/Dubai', label: 'Dubai', offset: '+4:00', region: 'Middle East & Africa', cities: ['dubai', 'uae', 'abu dhabi', 'sharjah', 'doha', 'qatar', 'bahrain', 'manama', 'muscat', 'oman', 'ajman'] }, + { value: 'Asia/Riyadh', label: 'Riyadh', offset: '+3:00', region: 'Middle East & Africa', cities: ['riyadh', 'saudi arabia', 'jeddah', 'mecca', 'medina', 'kuwait', 'dammam'] }, + + // South Asia + { value: 'Asia/Karachi', label: 'Karachi', offset: '+5:00', region: 'South Asia', cities: ['karachi', 'pakistan', 'lahore', 'islamabad', 'rawalpindi', 'faisalabad', 'peshawar'] }, + { value: 'Asia/Kolkata', label: 'Mumbai', offset: '+5:30', region: 'South Asia', cities: ['mumbai', 'bombay', 'india', 'new delhi', 'delhi', 'bangalore', 'bengaluru', 'chennai', 'hyderabad', 'kolkata', 'calcutta', 'pune', 'ahmedabad', 'jaipur', 'goa', 'lucknow', 'kanpur', 'agra', 'varanasi', 'surat'] }, + { value: 'Asia/Dhaka', label: 'Dhaka', offset: '+6:00', region: 'South Asia', cities: ['dhaka', 'bangladesh', 'chittagong', 'khulna', 'sylhet'] }, + + // Southeast Asia + { value: 'Asia/Bangkok', label: 'Bangkok', offset: '+7:00', region: 'Southeast Asia', cities: ['bangkok', 'thailand', 'phuket', 'chiang mai', 'pattaya', 'hanoi', 'vietnam', 'ho chi minh', 'saigon', 'da nang', 'krabi', 'koh samui', 'hoi an'] }, + { value: 'Asia/Jakarta', label: 'Jakarta', offset: '+7:00', region: 'Southeast Asia', cities: ['jakarta', 'indonesia', 'bali', 'surabaya', 'bandung', 'yogyakarta', 'medan', 'lombok'] }, + { value: 'Asia/Singapore', label: 'Singapore', offset: '+8:00', region: 'Southeast Asia', cities: ['singapore', 'kuala lumpur', 'malaysia', 'penang', 'johor bahru', 'langkawi', 'ipoh', 'malacca'] }, + { value: 'Asia/Manila', label: 'Manila', offset: '+8:00', region: 'Southeast Asia', cities: ['manila', 'philippines', 'cebu', 'davao', 'boracay', 'makati', 'palawan', 'baguio'] }, + + // East Asia + { value: 'Asia/Shanghai', label: 'Shanghai', offset: '+8:00', region: 'East Asia', cities: ['shanghai', 'china', 'beijing', 'peking', 'guangzhou', 'shenzhen', 'hong kong', 'macau', 'chengdu', 'hangzhou', 'nanjing', 'wuhan', 'xian', 'suzhou', 'tianjin', 'chongqing', 'qingdao', 'dalian', 'harbin', 'kunming', 'guilin'] }, + { value: 'Asia/Taipei', label: 'Taipei', offset: '+8:00', region: 'East Asia', cities: ['taipei', 'taiwan', 'kaohsiung', 'taichung', 'tainan'] }, + { value: 'Asia/Tokyo', label: 'Tokyo', offset: '+9:00', region: 'East Asia', cities: ['tokyo', 'japan', 'osaka', 'kyoto', 'yokohama', 'nagoya', 'sapporo', 'fukuoka', 'kobe', 'sendai', 'hiroshima', 'nara', 'okinawa', 'hokkaido', 'shibuya', 'shinjuku'] }, + { value: 'Asia/Seoul', label: 'Seoul', offset: '+9:00', region: 'East Asia', cities: ['seoul', 'korea', 'south korea', 'busan', 'incheon', 'daegu', 'daejeon', 'gangnam', 'jeju'] }, + + // Oceania + { value: 'Australia/Perth', label: 'Perth', offset: '+8:00', region: 'Oceania', cities: ['perth', 'western australia', 'fremantle'] }, + { value: 'Australia/Sydney', label: 'Sydney', offset: '+10:00', region: 'Oceania', cities: ['sydney', 'australia', 'melbourne', 'brisbane', 'gold coast', 'canberra', 'adelaide', 'hobart', 'cairns', 'darwin', 'tasmania', 'bondi', 'byron bay', 'great barrier reef'] }, + { value: 'Pacific/Auckland', label: 'Auckland', offset: '+12:00', region: 'Oceania', cities: ['auckland', 'new zealand', 'wellington', 'christchurch', 'queenstown', 'rotorua', 'dunedin', 'hamilton', 'tauranga'] }, +]; + +// Define regions in display order +const regionOrder = [ + 'UTC', + 'Americas', + 'Western Europe', + 'Central Europe', + 'Northern Europe', + 'Eastern Europe', + 'Middle East & Africa', + 'South Asia', + 'Southeast Asia', + 'East Asia', + 'Oceania', +]; + +// Build timezone options for display with region grouping +const getTimezoneOptions = (): { value: string; label: string; hint?: string; isGroup?: boolean }[] => { + const result: { value: string; label: string; hint?: string; isGroup?: boolean }[] = []; + + // Add local option first + result.push({ value: 'local', label: `Local (${browserTimezone})` }); + + // Check if browser timezone is not in the list, add it + const browserTzInList = timezoneData.some(tz => tz.value === browserTimezone); + if (!browserTzInList) { + result.push({ value: browserTimezone, label: browserTimezone }); + } + + // Group timezones by region + for (const region of regionOrder) { + const regionTimezones = timezoneData.filter(tz => tz.region === region); + if (regionTimezones.length > 0) { + // Add region header + result.push({ value: region, label: region, isGroup: true }); + // Add timezones in this region + for (const tz of regionTimezones) { + result.push({ + value: tz.value, + label: `${tz.label} (UTC${tz.offset})`, + }); + } + } + } + + return result; +}; + +// Search function that matches cities to timezones with hints +const searchTimezones = (query: string): { value: string; label: string; hint?: string; isGroup?: boolean }[] => { + const q = query.toLowerCase().trim(); + if (!q) return getTimezoneOptions(); + + const matches: { value: string; label: string; hint?: string; score: number }[] = []; + + // Check "local" option + if ('local'.includes(q) || browserTimezone.toLowerCase().includes(q)) { + matches.push({ value: 'local', label: `Local (${browserTimezone})`, score: 100 }); + } + + for (const tz of timezoneData) { + let score = 0; + let matchedCity: string | undefined; + + // Exact timezone value match + if (tz.value.toLowerCase().includes(q)) { + score = 90; + } + // Label match (timezone's main city name) + else if (tz.label.toLowerCase().includes(q)) { + score = 80; + } + // Offset match (e.g., "+1", "utc+1") + else if (tz.offset.includes(q) || `utc${tz.offset}`.includes(q)) { + score = 70; + } + // City alias match - find which city matched + else { + for (const city of tz.cities) { + if (city.includes(q)) { + // Prefer exact starts + score = city.startsWith(q) ? 85 : 75; + // Only show hint if not the main label city + if (city.toLowerCase() !== tz.label.toLowerCase()) { + // Capitalize first letter of each word + matchedCity = city.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + } + break; + } + } + } + + if (score > 0) { + matches.push({ + value: tz.value, + label: `${tz.label} (UTC${tz.offset})`, + hint: matchedCity, + score, + }); + } + } + + // Sort by score (highest first) + matches.sort((a, b) => b.score - a.score); + + return matches.map(({ value, label, hint }) => ({ value, label, hint })); +}; + +// Font family options - using system fonts for consistent local rendering +const fontFamilies = [ + { value: 'sans-serif', label: 'Sans-serif' }, + { value: 'monospace', label: 'Monospace' }, + { value: 'serif', label: 'Serif' }, +]; + +// Font weight options +const fontWeights = [ + { value: 'normal', label: 'Normal' }, + { value: 'bold', label: 'Bold' }, +]; + +// Text alignment options +const textAlignments = [ + { value: 'left', label: 'Left', icon: 'M4 6h16M4 12h10M4 18h14' }, + { value: 'center', label: 'Center', icon: 'M4 6h16M7 12h10M5 18h14' }, + { value: 'right', label: 'Right', icon: 'M4 6h16M10 12h10M6 18h14' }, +]; + +// Unit options +const unitOptions = [ + { value: 'metric', label: 'Celsius' }, + { value: 'imperial', label: 'Fahrenheit' }, +]; + +// Language/Locale options for date formatting +const localeOptions = [ + { value: 'en-US', label: 'English (US)' }, + { value: 'en-GB', label: 'English (UK)' }, + { value: 'pl-PL', label: 'Polish' }, + { value: 'de-DE', label: 'German' }, + { value: 'fr-FR', label: 'French' }, + { value: 'es-ES', label: 'Spanish' }, + { value: 'it-IT', label: 'Italian' }, + { value: 'pt-PT', label: 'Portuguese' }, + { value: 'nl-NL', label: 'Dutch' }, + { value: 'ru-RU', label: 'Russian' }, + { value: 'uk-UA', label: 'Ukrainian' }, + { value: 'ja-JP', label: 'Japanese' }, + { value: 'zh-CN', label: 'Chinese (Simplified)' }, + { value: 'ko-KR', label: 'Korean' }, +]; + +// Time format options +const timeFormats = [ + { value: '12h', label: '12 Hour' }, + { value: '24h', label: '24 Hour' }, +]; + +// Setting section component +interface SettingSectionProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +} + +function SettingSection({ title, children, defaultExpanded = true }: SettingSectionProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( +
    + + {isExpanded && ( +
    + {children} +
    + )} +
    + ); +} + +// Input field component +interface FieldProps { + label: string; + children: React.ReactNode; + hint?: string; +} + +function Field({ label, children, hint }: FieldProps) { + return ( +
    + + {children} + {hint &&

    {hint}

    } +
    + ); +} + +// Styled input component +function StyledInput({ className = '', ...props }: React.InputHTMLAttributes) { + return ( + + ); +} + +// Number input that allows clearing the field while typing +function NumberInput({ + value, + onChange, + min, + max, + step, + className = '', +}: { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + className?: string; +}) { + const [localValue, setLocalValue] = useState(String(value)); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (!isFocused) setLocalValue(String(value)); + }, [value, isFocused]); + + const clamp = (n: number) => { + let v = n; + if (min !== undefined) v = Math.max(min, v); + if (max !== undefined) v = Math.min(max, v); + return v; + }; + + return ( + setIsFocused(true)} + onChange={(e) => { + setLocalValue(e.target.value); + const parsed = parseFloat(e.target.value); + if (!isNaN(parsed)) onChange(clamp(parsed)); + }} + onBlur={() => { + setIsFocused(false); + const parsed = parseFloat(localValue); + if (isNaN(parsed)) setLocalValue(String(value)); + else onChange(clamp(parsed)); + }} + /> + ); +} + +// Styled select component +interface StyledSelectProps extends React.SelectHTMLAttributes { + options: { value: string; label: string }[]; +} + +function StyledSelect({ options, className = '', ...props }: StyledSelectProps) { + return ( + + ); +} + +// Toggle switch component +interface ToggleSwitchProps { + id: string; + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +} + +function ToggleSwitch({ id, label, checked, onChange }: ToggleSwitchProps) { + return ( +