From ca537cfb1813705aa2de8ff41e4dc07afc311d1d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:44:14 -0300 Subject: [PATCH 001/121] feat: add generic Nextcloud communications application Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- appinfo/info.xml | 30 + appinfo/routes.php | 42 + composer.json | 43 + composer.lock | 3265 ++++++++ css/guest.css | 27 + img/app.svg | 3 + js/guest.js | 110 + js/loader.js | 155 + lib/Activity/BoardProvider.php | 45 + lib/Activity/BoardSetting.php | 21 + lib/AppInfo/Application.php | 97 + lib/AppInfo/CoreEventsListener.php | 64 + lib/AppInfo/FileCreatedListener.php | 31 + lib/AppInfo/LoginListener.php | 34 + lib/AppInfo/NativeEventListener.php | 45 + lib/AppInfo/RegisterFlowListener.php | 24 + lib/BackgroundJob/BoardBroadcastJob.php | 24 + lib/BackgroundJob/BoardPublishJob.php | 23 + lib/BackgroundJob/CleanupJob.php | 28 + lib/BackgroundJob/EventChannelDeliveryJob.php | 21 + lib/Command/Campaign.php | 105 + lib/Command/PublishDue.php | 56 + lib/Controller/BoardController.php | 100 + lib/Controller/CampaignController.php | 113 + .../CampaignManagementController.php | 230 + .../GatewayPreferencesController.php | 62 + lib/Controller/GuestBannerController.php | 27 + lib/Controller/PageController.php | 43 + lib/Controller/WebhookController.php | 86 + lib/Dashboard/BoardWidget.php | 52 + .../RegisterTriggerCapabilitiesEvent.php | 24 + lib/Events/AnnouncementPublished.php | 35 + .../Version1000Date20261107190000.php | 44 + .../Version1001Date20261107210000.php | 32 + .../Version1002Date20261107221500.php | 76 + .../Version1003Date20261107223000.php | 28 + .../Version1004Date20261107230000.php | 40 + .../Version1005Date20261108105000.php | 26 + .../Version1006Date20261108170000.php | 23 + .../Version1007Date20261108190600.php | 34 + .../Version1008Date20261108204500.php | 52 + .../Version1009Date20261108213000.php | 62 + .../Version1010Date20261108220000.php | 30 + .../Version1011Date20261108225500.php | 25 + .../Version1012Date20261108234000.php | 22 + .../Version1013Date20261108235500.php | 23 + .../Version1014Date20261109000500.php | 21 + .../Version1015Date20261109134000.php | 34 + .../Version1016Date20261109150000.php | 35 + lib/Notification/BoardNotifier.php | 53 + lib/Service/BoardPublicationService.php | 471 ++ lib/Service/BoardService.php | 339 + lib/Service/CampaignRepository.php | 744 ++ lib/Service/CtaUrl.php | 22 + .../DeliveryOutcomeUnknownException.php | 11 + lib/Service/DeliveryStore.php | 454 ++ lib/Service/EditorAccess.php | 53 + lib/Service/EventStore.php | 144 + lib/Service/FlowCampaignOperation.php | 69 + lib/Service/GatewayRecipientPreferences.php | 95 + lib/Service/GatewayTransport.php | 91 + lib/Service/NativeEventCatalog.php | 186 + lib/Service/NativeEventSubscriptions.php | 132 + lib/Service/PublicationLink.php | 23 + lib/Service/TriggerCatalog.php | 387 + lib/Service/WebRouteCatalog.php | 208 + package-lock.json | 6718 +++++++++++++++++ package.json | 38 + scripts/install-assets.mjs | 18 + src/AppHub.vue | 85 + src/CampaignAdmin.vue | 1520 ++++ src/CampaignDelivery.vue | 187 + src/CommunicationsBoard.vue | 366 + src/bannerAppearance.css | 27 + src/bannerAppearance.js | 118 + src/board.css | 64 + src/components/AutomationBuilder.vue | 427 ++ src/components/CampaignPresentation.vue | 84 + src/components/DeliveryChannelOption.vue | 57 + src/components/FlowCampaignPicker.vue | 46 + src/components/MarkdownEditor.vue | 735 ++ .../markdown-editor/HeadingMenu.vue | 264 + src/delivery.css | 99 + src/delivery.js | 17 + src/flow.css | 13 + src/flow.js | 25 + src/linkSafety.js | 24 + src/main.js | 9 + src/management.css | 1150 +++ templates/main.php | 6 + vite.config.js | 32 + vite.delivery.config.js | 27 + vite.flow.config.js | 27 + 93 files changed, 21337 insertions(+) create mode 100644 appinfo/info.xml create mode 100644 appinfo/routes.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 css/guest.css create mode 100644 img/app.svg create mode 100644 js/guest.js create mode 100644 js/loader.js create mode 100644 lib/Activity/BoardProvider.php create mode 100644 lib/Activity/BoardSetting.php create mode 100644 lib/AppInfo/Application.php create mode 100644 lib/AppInfo/CoreEventsListener.php create mode 100644 lib/AppInfo/FileCreatedListener.php create mode 100644 lib/AppInfo/LoginListener.php create mode 100644 lib/AppInfo/NativeEventListener.php create mode 100644 lib/AppInfo/RegisterFlowListener.php create mode 100644 lib/BackgroundJob/BoardBroadcastJob.php create mode 100644 lib/BackgroundJob/BoardPublishJob.php create mode 100644 lib/BackgroundJob/CleanupJob.php create mode 100644 lib/BackgroundJob/EventChannelDeliveryJob.php create mode 100644 lib/Command/Campaign.php create mode 100644 lib/Command/PublishDue.php create mode 100644 lib/Controller/BoardController.php create mode 100644 lib/Controller/CampaignController.php create mode 100644 lib/Controller/CampaignManagementController.php create mode 100644 lib/Controller/GatewayPreferencesController.php create mode 100644 lib/Controller/GuestBannerController.php create mode 100644 lib/Controller/PageController.php create mode 100644 lib/Controller/WebhookController.php create mode 100644 lib/Dashboard/BoardWidget.php create mode 100644 lib/Event/RegisterTriggerCapabilitiesEvent.php create mode 100644 lib/Events/AnnouncementPublished.php create mode 100644 lib/Migration/Version1000Date20261107190000.php create mode 100644 lib/Migration/Version1001Date20261107210000.php create mode 100644 lib/Migration/Version1002Date20261107221500.php create mode 100644 lib/Migration/Version1003Date20261107223000.php create mode 100644 lib/Migration/Version1004Date20261107230000.php create mode 100644 lib/Migration/Version1005Date20261108105000.php create mode 100644 lib/Migration/Version1006Date20261108170000.php create mode 100644 lib/Migration/Version1007Date20261108190600.php create mode 100644 lib/Migration/Version1008Date20261108204500.php create mode 100644 lib/Migration/Version1009Date20261108213000.php create mode 100644 lib/Migration/Version1010Date20261108220000.php create mode 100644 lib/Migration/Version1011Date20261108225500.php create mode 100644 lib/Migration/Version1012Date20261108234000.php create mode 100644 lib/Migration/Version1013Date20261108235500.php create mode 100644 lib/Migration/Version1014Date20261109000500.php create mode 100644 lib/Migration/Version1015Date20261109134000.php create mode 100644 lib/Migration/Version1016Date20261109150000.php create mode 100644 lib/Notification/BoardNotifier.php create mode 100644 lib/Service/BoardPublicationService.php create mode 100644 lib/Service/BoardService.php create mode 100644 lib/Service/CampaignRepository.php create mode 100644 lib/Service/CtaUrl.php create mode 100644 lib/Service/DeliveryOutcomeUnknownException.php create mode 100644 lib/Service/DeliveryStore.php create mode 100644 lib/Service/EditorAccess.php create mode 100644 lib/Service/EventStore.php create mode 100644 lib/Service/FlowCampaignOperation.php create mode 100644 lib/Service/GatewayRecipientPreferences.php create mode 100644 lib/Service/GatewayTransport.php create mode 100644 lib/Service/NativeEventCatalog.php create mode 100644 lib/Service/NativeEventSubscriptions.php create mode 100644 lib/Service/PublicationLink.php create mode 100644 lib/Service/TriggerCatalog.php create mode 100644 lib/Service/WebRouteCatalog.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/install-assets.mjs create mode 100644 src/AppHub.vue create mode 100644 src/CampaignAdmin.vue create mode 100644 src/CampaignDelivery.vue create mode 100644 src/CommunicationsBoard.vue create mode 100644 src/bannerAppearance.css create mode 100644 src/bannerAppearance.js create mode 100644 src/board.css create mode 100644 src/components/AutomationBuilder.vue create mode 100644 src/components/CampaignPresentation.vue create mode 100644 src/components/DeliveryChannelOption.vue create mode 100644 src/components/FlowCampaignPicker.vue create mode 100644 src/components/MarkdownEditor.vue create mode 100644 src/components/markdown-editor/HeadingMenu.vue create mode 100644 src/delivery.css create mode 100644 src/delivery.js create mode 100644 src/flow.css create mode 100644 src/flow.js create mode 100644 src/linkSafety.js create mode 100644 src/main.js create mode 100644 src/management.css create mode 100644 templates/main.php create mode 100644 vite.config.js create mode 100644 vite.delivery.config.js create mode 100644 vite.flow.config.js diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..5d64e51 --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,30 @@ + + + + communications + Comunicações + Publicações, avisos e mensagens em um só lugar no Nextcloud + Publicações institucionais, notificações e mensagens contextuais com uma caixa de entrada unificada. + 0.29.0 + AGPL-3.0-or-later + LibreCode Coop + Communications + tools + https://github.com/LibreCodeCoop/communications/issues + https://github.com/LibreCodeCoop/communications + + + OCA\Communications\BackgroundJob\CleanupJob + OCA\Communications\BackgroundJob\BoardPublishJob + + + OCA\Communications\Command\Campaign + OCA\Communications\Command\PublishDue + + + OCA\Communications\Activity\BoardSetting + OCA\Communications\Activity\BoardProvider + + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..d6dff46 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,42 @@ + [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ['name' => 'page#board', 'url' => '/board', 'verb' => 'GET'], + ['name' => 'board#index', 'url' => '/api/board', 'verb' => 'GET'], + ['name' => 'board#show', 'url' => '/api/board/{id}', 'verb' => 'GET'], + ['name' => 'board#markRead', 'url' => '/api/board/{id}/read', 'verb' => 'POST'], + ['name' => 'board#comments', 'url' => '/api/board/{id}/comments', 'verb' => 'GET'], + ['name' => 'board#createComment', 'url' => '/api/board/{id}/comments', 'verb' => 'POST'], + ['name' => 'board#mentions', 'url' => '/api/board/{id}/mentions', 'verb' => 'GET'], + ['name' => 'board#deleteComment', 'url' => '/api/board/{id}/comments/{commentId}', 'verb' => 'DELETE'], + ['name' => 'campaign#pending', 'url' => '/api/pending', 'verb' => 'GET'], + ['name' => 'campaign#claim', 'url' => '/api/claim', 'verb' => 'POST'], + ['name' => 'campaign#claimBatch', 'url' => '/api/claim-batch', 'verb' => 'POST'], + ['name' => 'campaign#impression', 'url' => '/api/impression', 'verb' => 'POST'], + ['name' => 'campaign#click', 'url' => '/api/click', 'verb' => 'POST'], + ['name' => 'guest_banner#index', 'url' => '/api/public-banners', 'verb' => 'GET'], + ['name' => 'gateway_preferences#index', 'url' => '/api/gateway-preferences', 'verb' => 'GET'], + ['name' => 'gateway_preferences#update', 'url' => '/api/gateway-preferences', 'verb' => 'POST'], + ['name' => 'campaign#preferences', 'url' => '/api/preferences', 'verb' => 'GET'], + ['name' => 'campaign#setPreferences', 'url' => '/api/preferences', 'verb' => 'POST'], + ['name' => 'webhook#ingest', 'url' => '/api/hooks/event', 'verb' => 'POST'], + ['name' => 'campaign#acknowledge', 'url' => '/api/ack', 'verb' => 'POST'], + ['name' => 'campaign_management#editorGroups', 'url' => '/api/editor-groups', 'verb' => 'GET'], + ['name' => 'campaign_management#setEditorGroups', 'url' => '/api/editor-groups', 'verb' => 'POST'], + ['name' => 'campaign_management#index', 'url' => '/api/campaigns', 'verb' => 'GET'], + ['name' => 'campaign_management#catalog', 'url' => '/api/catalog', 'verb' => 'GET'], + ['name' => 'campaign_management#routes', 'url' => '/api/routes', 'verb' => 'GET'], + ['name' => 'campaign_management#users', 'url' => '/api/users', 'verb' => 'GET'], + ['name' => 'campaign_management#show', 'url' => '/api/campaigns/{id}', 'verb' => 'GET'], + ['name' => 'campaign_management#metrics', 'url' => '/api/campaigns/{id}/metrics', 'verb' => 'GET'], + ['name' => 'campaign_management#create', 'url' => '/api/campaigns', 'verb' => 'POST'], + ['name' => 'campaign_management#update', 'url' => '/api/campaigns/{id}', 'verb' => 'PUT'], + ['name' => 'campaign_management#status', 'url' => '/api/campaigns/{id}/status', 'verb' => 'POST'], + ['name' => 'campaign_management#move', 'url' => '/api/campaigns/{id}/move', 'verb' => 'POST'], + ['name' => 'campaign_management#duplicate', 'url' => '/api/campaigns/{id}/duplicate', 'verb' => 'POST'], + ['name' => 'campaign_management#archive', 'url' => '/api/campaigns/{id}', 'verb' => 'DELETE'], + ['name' => 'campaign_management#purge', 'url' => '/api/campaigns/{id}/purge', 'verb' => 'POST'], +]]; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bb5732b --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "librecode/communications", + "description": "Nextcloud institutional publications and event-triggered communications", + "type": "project", + "license": "AGPL-3.0-or-later", + "require": { + "php": ">=8.3" + }, + "require-dev": { + "nextcloud/ocp": "^35.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.5", + "symfony/console": "^7.4", + "symfony/routing": "^7.4" + }, + "autoload": { + "psr-4": { + "OCA\\Communications\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "OCA\\Communications\\Tests\\": "tests/php/" + } + }, + "scripts": { + "lint": "bash scripts/ci/php-lint.sh", + "test": "phpunit --configuration phpunit.xml.dist", + "analyse": "phpstan analyse --configuration phpstan.neon.dist --no-progress", + "check": [ + "@lint", + "@test", + "@analyse" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": {}, + "platform": { + "php": "8.3.0" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..9145bca --- /dev/null +++ b/composer.lock @@ -0,0 +1,3265 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2dc4e653da62f0baf5e88016aba8da15", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00", + "published-time": "2026-08-11T10:29:20+00:00" + }, + { + "name": "nextcloud/ocp", + "version": "v35.0.2", + "source": { + "type": "git", + "url": "https://github.com/nextcloud-deps/ocp.git", + "reference": "1f372f9d20bc38860d1d11a55c1c16293b759e6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/1f372f9d20bc38860d1d11a55c1c16293b759e6c", + "reference": "1f372f9d20bc38860d1d11a55c1c16293b759e6c", + "shasum": "" + }, + "require": { + "php": "~8.3 || ~8.4 || ~8.5", + "psr/clock": "^1.0", + "psr/container": "^2.0.2", + "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0.3", + "psr/log": "^3.0.2", + "symfony/polyfill-intl-normalizer": "^1.38", + "symfony/polyfill-php84": "^1.38", + "symfony/polyfill-php85": "^1.41", + "symfony/polyfill-php86": "^1.41" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-stable35": "35.0.0-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Christoph Wurst", + "email": "christoph@winzerhof-wurst.at" + }, + { + "name": "Joas Schilling", + "email": "coding@schilljs.com" + } + ], + "description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", + "support": { + "issues": "https://github.com/nextcloud-deps/ocp/issues", + "source": "https://github.com/nextcloud-deps/ocp/tree/v35.0.2" + }, + "time": "2026-10-09T03:32:50+00:00", + "published-time": "2026-10-16T02:06:59+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00", + "published-time": "2026-09-13T18:53:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00", + "published-time": "2024-03-03T12:39:44+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00", + "published-time": "2022-02-21T01:04:30+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.3.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/645a1bd6bead15fc1b8474137467961d0fa30a79", + "reference": "645a1bd6bead15fc1b8474137467961d0fa30a79", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-11-03T12:14:22+00:00", + "published-time": "2026-11-03T12:14:36+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00", + "published-time": "2025-12-24T07:02:33+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00", + "published-time": "2026-02-02T13:54:13+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00", + "published-time": "2024-07-03T05:07:59+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00", + "published-time": "2024-07-03T05:08:52+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00", + "published-time": "2024-07-03T05:09:48+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.57", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:47:54+00:00", + "published-time": "2026-10-05T05:48:42+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00", + "published-time": "2022-11-25T14:59:22+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00", + "published-time": "2021-11-05T16:53:27+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00", + "published-time": "2019-03-25T15:31:44+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00", + "published-time": "2023-09-23T14:18:12+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00", + "published-time": "2023-04-04T09:55:35+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00", + "published-time": "2024-09-11T13:21:14+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00", + "published-time": "2024-07-03T04:42:56+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00", + "published-time": "2025-03-19T07:56:37+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00", + "published-time": "2024-07-03T04:46:11+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00", + "published-time": "2026-01-24T09:27:07+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00", + "published-time": "2024-07-03T04:50:20+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00", + "published-time": "2024-07-03T04:53:18+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00", + "published-time": "2025-05-21T11:56:27+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00", + "published-time": "2025-09-24T06:14:38+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00", + "published-time": "2024-07-03T04:57:52+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00", + "published-time": "2024-07-03T04:58:50+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00", + "published-time": "2024-07-03T05:00:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00", + "published-time": "2024-07-03T05:01:45+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00", + "published-time": "2025-08-13T04:42:55+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00", + "published-time": "2025-08-09T07:00:14+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00", + "published-time": "2024-10-09T05:17:00+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00", + "published-time": "2024-10-20T05:09:39+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "81957c2440c05c962fe8291f318de6e85f8db330" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/81957c2440c05c962fe8291f318de6e85f8db330", + "reference": "81957c2440c05c962fe8291f318de6e85f8db330", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-03T21:47:07+00:00", + "published-time": "2026-11-04T11:27:31+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:34+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00", + "published-time": "2026-04-26T14:44:50+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5a8f33c87aeecffce24e721b5064fab05f4becf8", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-15T09:18:53+00:00", + "published-time": "2026-10-28T10:07:44+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00", + "published-time": "2026-09-30T10:23:47+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T09:35:49+00:00", + "published-time": "2026-10-28T10:07:45+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00", + "published-time": "2026-09-30T10:23:33+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:32+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/8840df45b24f33a49dc419fa18dc0057231ae1d8", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:43+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/0000ade604b7bc798cb1e75edf6f17df48185f73", + "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:04+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-20T17:38:25+00:00", + "published-time": "2026-11-04T11:25:33+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "8dff7da3a56638f1d189c5368183294102de4ced" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/8dff7da3a56638f1d189c5368183294102de4ced", + "reference": "8dff7da3a56638f1d189c5368183294102de4ced", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-23T16:51:52+00:00", + "published-time": "2026-10-28T18:21:18+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00", + "published-time": "2025-11-17T20:04:52+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, + "plugin-api-version": "2.11.0" +} diff --git a/css/guest.css b/css/guest.css new file mode 100644 index 0000000..66ba43f --- /dev/null +++ b/css/guest.css @@ -0,0 +1,27 @@ +/* SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +.cc-guest-stack { + position:relative;z-index:9000;box-sizing:border-box; + display:grid;gap:6px;padding:8px clamp(10px,3vw,28px); + background:var(--color-main-background,#fff);color:var(--color-main-text,#202020); + border-bottom:1px solid var(--color-border,#ddd); +} +.cc-guest-item { + --cc-guest-accent:#1b79ae; + display:flex;align-items:flex-start;justify-content:space-between;gap:14px; + padding:12px 16px;border-radius:8px;border-inline-start:4px solid var(--cc-guest-accent); + background:color-mix(in srgb,var(--cc-guest-accent) 11%,var(--color-main-background,#fff)); + box-shadow:0 1px 3px #0001; +} +.cc-guest-item[data-type=success]{--cc-guest-accent:#237c49} +.cc-guest-item[data-type=warning]{--cc-guest-accent:#ae760c} +.cc-guest-item[data-type=error]{--cc-guest-accent:#bb3540} +.cc-guest-item[data-type=neutral]{--cc-guest-accent:#888} +.cc-guest-copy{min-width:0;display:grid;gap:4px;line-height:1.45;} +.cc-guest-headline{font-size:15px;} +.cc-guest-message{font-size:14px;white-space:pre-wrap;overflow-wrap:anywhere} +.cc-guest-link{color:var(--color-primary-element,#0872b9);text-decoration:underline;font-weight:600;justify-self:start} +.cc-guest-close{border:0;background:transparent;color:inherit;border-radius:8px; + cursor:pointer;font-size:23px;line-height:1;min-width:34px;min-height:34px} +.cc-guest-close:hover,.cc-guest-close:focus-visible{outline:2px solid var(--cc-guest-accent)} +@media(max-width:480px){.cc-guest-item{padding:10px;gap:6px}} diff --git a/img/app.svg b/img/app.svg new file mode 100644 index 0000000..f45bb13 --- /dev/null +++ b/img/app.svg @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/js/guest.js b/js/guest.js new file mode 100644 index 0000000..25b7282 --- /dev/null +++ b/js/guest.js @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +/* SPDX-License-Identifier: AGPL-3.0-or-later + * Light public-only banner adapter. No personal cookies, tracking, HTML + * interpolation, remote asset loading, or authenticated delivery endpoints. + */ +(() => { + 'use strict' + const appRoot=(window.OC?.webroot||'')+'/index.php/apps/communications' + const storagePrefix='cc-public-dismissed-' + const stack=document.createElement('section') + stack.className='cc-guest-stack' + stack.setAttribute('aria-label','Avisos públicos') + stack.setAttribute('role','region') + let disposed=false + let pending=false + let timer=null + const allowedTypes=new Set(['info','default','primary','success','warning','error','custom','neutral']) + function safeUrl(value) { + if(typeof value!=='string'||!value||value.length>1900||/[\x00-\x20\x7f"'<>\x60\\]/.test(value))return '' + if(value.startsWith('/')){ + if(value.startsWith('//'))return '' + const webroot=window.OC?.webroot||'' + return webroot&&!value.startsWith(webroot+'/')?webroot+value:value + } + if(/^https?:\/\//i.test(value)){ + try{ + const parsed=new URL(value) + return ['http:','https:'].includes(parsed.protocol)&&parsed.hostname&&!parsed.username&&!parsed.password?value:'' + }catch{return ''} + } + if(/^mailto:[^@\s]+@[^@\s]+$/i.test(value)||/^tel:\+?[0-9().-]{3,30}$/i.test(value))return value + return '' + } + function dismissed(item) { + try {return localStorage.getItem(storagePrefix+item.id)===String(item.version)} + catch{return false} + } + function markDismissed(item) { + try {localStorage.setItem(storagePrefix+item.id,String(item.version))} + catch{} + } + function text(tag,content,cls){ + const element=document.createElement(tag) + if(cls)element.className=cls + element.textContent=String(content??'') + return element + } + function present(item) { + const entry=document.createElement('article') + entry.className='cc-guest-item' + const type=allowedTypes.has(item.type)?item.type:'info' + entry.dataset.type=type + if(type==='custom'&&/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(item.color||''))entry.style.setProperty('--cc-guest-accent',item.color) + const body=document.createElement('div') + body.className='cc-guest-copy' + body.append(text('strong',item.headline,'cc-guest-headline')) + body.append(text('span',item.message,'cc-guest-message')) + const link=safeUrl(item.ctaUrl) + if(item.ctaLabel&&link){ + const anchor=text('a',item.ctaLabel,'cc-guest-link') + anchor.href=link + if(/^https?:\/\//i.test(link)){ + anchor.target='_blank' + anchor.rel='noopener noreferrer' + } + body.append(anchor) + } + entry.append(body) + if(item.dismissible!==false){ + const close=text('button','×','cc-guest-close') + close.type='button' + close.setAttribute('aria-label','Dispensar aviso: '+String(item.headline).slice(0,140)) + close.addEventListener('click',()=>{ + markDismissed(item) + entry.remove() + if(!stack.childElementCount)stack.remove() + }) + entry.append(close) + } + return entry + } + async function scan(){ + if(disposed||pending||document.visibilityState==='hidden')return + pending=true + try{ + const r=await fetch(appRoot+'/api/public-banners',{credentials:'omit',cache:'no-store',headers:{Accept:'application/json'}}) + if(!r.ok)return + const data=await r.json() + const items=Array.isArray(data.banners)?data.banners:[] + stack.replaceChildren() + for(const item of items.slice(0,5)){ + if(!item||!Number.isSafeInteger(item.id)||dismissed(item))continue + stack.append(present(item)) + } + if(stack.childElementCount){ + if(!stack.isConnected)document.body.prepend(stack) + }else stack.remove() + }catch{ + // The login and public share pages continue to function if unavailable. + }finally{pending=false} + } + function start(){ + if(!document.body||document.body.id==='body-user')return + void scan() + timer=setInterval(()=>void scan(),15000) + document.addEventListener('visibilitychange',scan) + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true}) + else start() +})() diff --git a/js/loader.js b/js/loader.js new file mode 100644 index 0000000..48415a4 --- /dev/null +++ b/js/loader.js @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +/* SPDX-License-Identifier: AGPL-3.0-or-later + * Lightweight Nextcloud page adapter. + * Vue delivery and its CSS are loaded only if a server-side campaign is eligible. + * There are no external requests, trackers or advertisement networks. + */ +(() => { + 'use strict' + + const originScript = document.currentScript?.src + if (!originScript) return + const loaderUrl = new URL(originScript, location.href) + const jsDirectory = loaderUrl.pathname.replace(/\/loader\.js$/, '/') + if (jsDirectory === loaderUrl.pathname) return + const stylesheetUrl = new URL(jsDirectory.replace(/\/js\/$/, '/css/') + 'campaigns.css', location.origin) + const bundleUrl = new URL(jsDirectory + 'campaigns.js', location.origin) + // Only use code delivered by this Nextcloud installation. + if (stylesheetUrl.origin !== location.origin || bundleUrl.origin !== location.origin) return + + const base = (window.OC?.webroot || '') + '/index.php/apps/communications' + let firstVisit = true + let lastApp = null + let lastPath = null + let busy = false + let loaded = false + let timer = null + let retries = 0 + let retryTimer = null + let updates = null + + function onChange() { void scan() } + function onStorage(event) { + if (event.key === 'communications-campaign-update') void scan() + } + + function appContext() { + const match = location.pathname.match(/\/(?:index\.php\/)?apps\/([a-z0-9_-]{2,32})(?:\/|$)/) + if (match) return match[1] + if (/\/settings\/admin(?:\/|$)/.test(location.pathname)) return 'admin_settings' + if (/\/settings(?:\/|$)/.test(location.pathname)) return 'settings' + return 'any' + } + + function stop() { + if (timer !== null) clearInterval(timer) + if (retryTimer !== null) clearTimeout(retryTimer) + retryTimer = null + document.removeEventListener('visibilitychange', onVisible) + window.removeEventListener('popstate', scan) + window.removeEventListener('hashchange', scan) + document.removeEventListener('nextcloud.navigation.entered', scan) + window.removeEventListener('storage', onStorage) + if (updates) { updates.close(); updates = null } + } + + function inject() { + return new Promise((resolve, reject) => { + if (!document.querySelector('link[data-communications-delivery]')) { + const css = document.createElement('link') + css.rel = 'stylesheet' + css.href = stylesheetUrl.href + css.dataset.communicationsDelivery = '1' + document.head.appendChild(css) + } + const script = document.createElement('script') + script.async = true + script.src = bundleUrl.href + script.onload = resolve + script.onerror = () => reject(new Error('Campaign bundle unavailable')) + document.head.appendChild(script) + }) + } + + async function scan() { + if (loaded || busy || document.visibilityState === 'hidden') return + if (document.body.id !== 'body-user') return + if (location.pathname.includes('/apps/communications/')) return + busy = true + const context = appContext() + const path = location.pathname + const newVisit = firstVisit || context !== lastApp || path !== lastPath + try { + const response = await fetch(base + '/api/claim', { + method: 'POST', credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + requesttoken: window.OC?.requestToken || window.oc_requesttoken || '', + }, + body: JSON.stringify({page: context, path, newVisit}), + }) + if (!response.ok) throw new Error('Claim HTTP '+response.status) + const data = await response.json() + retries = 0 + if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null } + firstVisit = false + lastApp = context + lastPath = path + if (data.campaign) { + loaded = true + window.__contextualCampaignInitial = data.campaign + stop() + try { + await inject() + } catch (error) { + delete window.__contextualCampaignInitial + loaded = false + timer = setInterval(scan, 30000) + console.warn('[communications] lazy loader failed:',error) + } + } + } catch (error) { + // Nextcloud's initial async bootstrap may not have exposed its CSRF + // token yet. A single lost preflight previously delayed the next check + // by 30 seconds; a short, bounded retry avoids missing the first visit. + // Never retry indefinitely or after a working campaign was loaded. + if (!loaded && retries < 4 && retryTimer === null) { + const delay = Math.min(1000 * (2 ** retries), 8000) + retries++ + retryTimer = setTimeout(() => { + retryTimer = null + void scan() + }, delay) + } + console.warn('[communications] preflight unavailable:',error) + } finally { + busy = false + } + } + + function onVisible() { + if (document.visibilityState === 'visible') void scan() + } + function start() { + if (document.body.id !== 'body-user') return + if (location.pathname.includes('/apps/communications/')) return + void scan() + timer = setInterval(scan, 30000) + document.addEventListener('visibilitychange', onVisible) + window.addEventListener('popstate', scan) + window.addEventListener('hashchange', scan) + document.addEventListener('nextcloud.navigation.entered', scan) + window.addEventListener('storage', onStorage) + if (typeof BroadcastChannel !== 'undefined') { + updates = new BroadcastChannel('communications') + updates.onmessage = event => { + if (event.data?.event === 'changed') void scan() + } + } + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start, {once:true}) + } else { + start() + } +})() diff --git a/lib/Activity/BoardProvider.php b/lib/Activity/BoardProvider.php new file mode 100644 index 0000000..6f1e070 --- /dev/null +++ b/lib/Activity/BoardProvider.php @@ -0,0 +1,45 @@ +getApp()!=='communications' + || $event->getSubject()!=='board_published' + || $event->getObjectType()!=='cc_board_announcement') { + throw new UnknownActivityException(); + } + try { + $uid=$this->activities->getCurrentUserId(); + } catch (\Throwable $error) { + throw new UnknownActivityException('Missing activity viewer',0,$error); + } + if ($event->getAffectedUser()!==$uid) { + throw new UnknownActivityException('This activity belongs to another account'); + } + $post=$this->board->view($uid,(int)$event->getObjectId()); + if ($post===null) { throw new UnknownActivityException('Announcement is unavailable'); } + return $event + ->setParsedSubject('Novo comunicado: '.$post['headline']) + ->setParsedMessage(mb_substr(trim(strip_tags($post['message'])),0,350)) + ->setLink(PublicationLink::absolute($this->urls,(int)$post['id'])) + ->setIcon($this->urls->getAbsoluteURL($this->urls->imagePath('communications','app.svg'))); + } +} diff --git a/lib/Activity/BoardSetting.php b/lib/Activity/BoardSetting.php new file mode 100644 index 0000000..b067619 --- /dev/null +++ b/lib/Activity/BoardSetting.php @@ -0,0 +1,21 @@ +registerDashboardWidget(\OCA\Communications\Dashboard\BoardWidget::class); + $context->registerNotifierService(\OCA\Communications\Notification\BoardNotifier::class); + $context->registerEventListener(UserLoggedInEvent::class, LoginListener::class); + $context->registerEventListener(NodeCreatedEvent::class, FileCreatedListener::class); + // Nextcloud registers bootstrap listeners for ALL requests, including DAV, + // before an individual app may be booted. Dynamic listeners belong HERE, + // not in boot(), or non-UI events can be missed. + $compiled = $this->getContainer()->get(\OCP\IAppConfig::class) + ->getValueString('communications', 'native_active_event_subscriptions_v1', '[]'); + // Mutating events do not subscribe during safe HTTP GET/HEAD/OPTIONS. + // This preserves fast page views even when a File-created campaign is active. + foreach (NativeEventSubscriptions::bootstrapClasses($compiled, (string)($_SERVER['REQUEST_METHOD'] ?? '')) as $class) { + $context->registerEventListener($class, NativeEventListener::class, -100); + } + $context->registerEventListener(RegisterOperationsEvent::class, RegisterFlowListener::class); + $context->registerEventListener(LoadSettingsScriptsEvent::class, RegisterFlowListener::class); + foreach ([ + UserFirstTimeLoggedInEvent::class, + NodeWrittenEvent::class, + NodeDeletedEvent::class, + ShareCreatedEvent::class, + ShareAcceptedEvent::class, + CommentAddedEvent::class, + ] as $eventClass) { + $context->registerEventListener($eventClass, CoreEventsListener::class); + } + } + + public function boot(IBootContext $context): void { + // CLI jobs have no browser path or navigation context. + if (PHP_SAPI === 'cli') { return; } + $server = $context->getServerContainer(); + $user = $server->get(IUserSession::class)->getUser(); + // Guest banners use a deliberately small and public-only script, not the + // authenticated campaign engine. Only enable on browser-visible public pages. + $path = $server->get(IRequest::class)->getPathInfo(); + if ($user===null && (preg_match('~^/(?:index\.php/)?login/?$~',$path) + || preg_match('~^/(?:index\.php/)?(?:s|f)/[A-Za-z0-9_-]{8,120}/?$~',$path))) { + Util::addScript('communications','guest'); + Util::addStyle('communications','guest'); + } + if ($user !== null && !str_contains($path, '/apps/communications')) { + // Most pages have no eligible campaign: load the small preflight script + // and lazily inject the Vue renderer only after an actual claim. + Util::addScript('communications', 'loader'); + } + if ($user !== null) { + $server->get(INavigationManager::class)->add(function() use ($server): array { + $urls = $server->get(IURLGenerator::class); + return [ + 'id' => 'communications', + 'app' => 'communications', + 'type' => 'link', + 'order' => 97, + 'name' => 'Comunicações', + 'href' => $urls->linkToRoute('communications.page.index'), + 'icon' => $urls->imagePath('communications', 'app.svg'), + ]; + }); + } + } +} diff --git a/lib/AppInfo/CoreEventsListener.php b/lib/AppInfo/CoreEventsListener.php new file mode 100644 index 0000000..ec27738 --- /dev/null +++ b/lib/AppInfo/CoreEventsListener.php @@ -0,0 +1,64 @@ +getUser()->getUID(); + $trigger = 'nextcloud.user.first_login'; + } elseif ($event instanceof ShareCreatedEvent) { + $uid = $event->getShare()->getSharedBy(); + $trigger = 'nextcloud.share.created'; + $app = 'files'; + } elseif ($event instanceof ShareAcceptedEvent) { + $share = $event->getShare(); + if ($share->getShareType() !== IShare::TYPE_USER) { return; } + $uid = $share->getSharedWith(); + $trigger = 'nextcloud.share.accepted'; + $app = 'files'; + } elseif ($event instanceof NodeWrittenEvent || $event instanceof NodeDeletedEvent) { + $node = $event->getNode(); + if (!$node instanceof File) { return; } + $owner = $node->getOwner(); + if ($owner === null || !str_starts_with($node->getPath(), '/' . $owner->getUID() . '/files/')) { + return; + } + $uid = $owner->getUID(); + $trigger = $event instanceof NodeWrittenEvent ? 'nextcloud.files.updated' : 'nextcloud.files.deleted'; + $app = 'files'; + } elseif ($event instanceof CommentAddedEvent) { + $comment = $event->getComment(); + if ($comment->getActorType() !== 'users') { return; } + $uid = $comment->getActorId(); + $trigger = 'nextcloud.comment.created'; + } + if (!is_string($uid) || $uid === '' || $trigger === null) { return; } + try { + $this->events->record($uid, $trigger, $app); + } catch (\Throwable $e) { + // Campaign failures must never roll back Files, sharing or comments. + $this->logger->warning('Contextual campaigns event could not be recorded', ['exception' => $e]); + } + } +} diff --git a/lib/AppInfo/FileCreatedListener.php b/lib/AppInfo/FileCreatedListener.php new file mode 100644 index 0000000..a9d4f04 --- /dev/null +++ b/lib/AppInfo/FileCreatedListener.php @@ -0,0 +1,31 @@ +getNode(); + if (!$node instanceof File) { return; } + $owner = $node->getOwner(); + if ($owner === null || !str_starts_with($node->getPath(), '/' . $owner->getUID() . '/files/')) { + return; + } + try { + $this->events->record($owner->getUID(), 'nextcloud.files.created', 'files'); + } catch (\Throwable $e) { + $this->logger->warning('Contextual campaigns file event failed', ['exception' => $e]); + } + } +} diff --git a/lib/AppInfo/LoginListener.php b/lib/AppInfo/LoginListener.php new file mode 100644 index 0000000..f11fad9 --- /dev/null +++ b/lib/AppInfo/LoginListener.php @@ -0,0 +1,34 @@ +request->getMethod() !== 'POST' + || !str_ends_with($this->request->getPathInfo(), '/login')) { + return; + } + try { + $this->events->record($event->getUser()->getUID(), 'nextcloud.user.login'); + } catch (\Throwable $e) { + $this->logger->warning('Contextual campaigns login event could not be persisted', ['exception' => $e]); + } + } +} diff --git a/lib/AppInfo/NativeEventListener.php b/lib/AppInfo/NativeEventListener.php new file mode 100644 index 0000000..4433cdb --- /dev/null +++ b/lib/AppInfo/NativeEventListener.php @@ -0,0 +1,45 @@ + + */ +final class NativeEventListener implements IEventListener { + public function __construct( + private NativeEventSubscriptions $subscriptions, + private EventStore $events, + private IUserSession $session, + private LoggerInterface $logger, + ) {} + + public function handle(Event $event): void { + $meta = $this->subscriptions->forClass($event::class); + if ($meta === null) { return; } + $user = $this->session->getUser(); + if ($user === null || !$user->isEnabled()) { return; } + try { + // Do not call event getters; they may expose documents, tokens, + // signatures or owner accounts and can perform arbitrary work. + $this->events->record($user->getUID(), $meta['key'], $meta['appId']); + } catch (\Throwable $error) { + // Campaign failures must not break the host application's operation. + $this->logger->warning('Contextual campaigns could not capture a typed event', [ + 'exceptionClass' => get_class($error), + ]); + } + } +} diff --git a/lib/AppInfo/RegisterFlowListener.php b/lib/AppInfo/RegisterFlowListener.php new file mode 100644 index 0000000..bdd3db4 --- /dev/null +++ b/lib/AppInfo/RegisterFlowListener.php @@ -0,0 +1,24 @@ +registerOperation($this->operation); + } elseif ($event instanceof LoadSettingsScriptsEvent) { + Util::addScript('communications','flow','workflowengine'); + Util::addStyle('communications','flow'); + } + } +} diff --git a/lib/BackgroundJob/BoardBroadcastJob.php b/lib/BackgroundJob/BoardBroadcastJob.php new file mode 100644 index 0000000..2d065b0 --- /dev/null +++ b/lib/BackgroundJob/BoardBroadcastJob.php @@ -0,0 +1,24 @@ +publisher->broadcast( + (int)($argument['id'] ?? 0), + (int)($argument['version'] ?? 0), + (int)($argument['offset'] ?? 0) + ); + } +} diff --git a/lib/BackgroundJob/BoardPublishJob.php b/lib/BackgroundJob/BoardPublishJob.php new file mode 100644 index 0000000..a087374 --- /dev/null +++ b/lib/BackgroundJob/BoardPublishJob.php @@ -0,0 +1,23 @@ +setInterval(60); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + protected function run($argument):void { + $this->publisher->publishDue(100); + $this->publisher->retryEventDeliveries(100); + } +} diff --git a/lib/BackgroundJob/CleanupJob.php b/lib/BackgroundJob/CleanupJob.php new file mode 100644 index 0000000..43c1e7a --- /dev/null +++ b/lib/BackgroundJob/CleanupJob.php @@ -0,0 +1,28 @@ +setInterval(86400); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + protected function run($argument): void { + $cutoff = $this->time->getTime() - EventStore::RETENTION_SECONDS; + $this->deliveries->prune($cutoff); + $this->events->prune($cutoff); + } +} diff --git a/lib/BackgroundJob/EventChannelDeliveryJob.php b/lib/BackgroundJob/EventChannelDeliveryJob.php new file mode 100644 index 0000000..b1fd768 --- /dev/null +++ b/lib/BackgroundJob/EventChannelDeliveryJob.php @@ -0,0 +1,21 @@ +publisher->dispatchEvent($argument['eventId']); + } +} diff --git a/lib/Command/Campaign.php b/lib/Command/Campaign.php new file mode 100644 index 0000000..51331b0 --- /dev/null +++ b/lib/Command/Campaign.php @@ -0,0 +1,105 @@ +setName('communications:campaign') + ->setDescription('List, create, update, archive or permanently delete publications') + ->addArgument('action',InputArgument::REQUIRED,'list | show | create | update | archive | delete') + ->addArgument('id',InputArgument::OPTIONAL,'Campaign ID for show, update or archive') + ->addOption('json',null,InputOption::VALUE_REQUIRED,'Full or partial campaign configuration as JSON') + ->addOption('file',null,InputOption::VALUE_REQUIRED,'Read JSON payload from file (recommended)') + ->addOption('actor',null,InputOption::VALUE_REQUIRED,'Existing Nextcloud UID for new announcements') + ->addOption('confirm-name',null,InputOption::VALUE_REQUIRED,'Exact internal name for irreversible delete'); + } + protected function execute(InputInterface $input,OutputInterface $output):int { + try { + $action=strtolower((string)$input->getArgument('action')); + $rawId=$input->getArgument('id'); + $id=$rawId !== null && ctype_digit((string)$rawId) ? (int)$rawId : 0; + if ($action==='list') { + $this->emit($output,$this->campaigns->all()); + return self::SUCCESS; + } + if (in_array($action,['show','update','archive','delete'],true) && $id <= 0) { + throw new InvalidArgumentException('Specify a positive campaign ID.'); + } + if ($action==='show') { + $c=$this->campaigns->find($id); + if ($c===null) { throw new InvalidArgumentException('Campaign not found.'); } + $this->emit($output,$c); + return self::SUCCESS; + } + if ($action==='archive') { + $c=$this->campaigns->setStatus($id,'archived'); + if ($c===null) { throw new InvalidArgumentException('Campaign not found.'); } + $this->emit($output,['id'=>$id,'status'=>'archived']); + return self::SUCCESS; + } + if ($action==='delete') { + $name=(string)($input->getOption('confirm-name')??''); + if ($name==='') { throw new InvalidArgumentException('Provide --confirm-name with the exact internal name.'); } + if (!$this->campaigns->purgeArchived($id,$name)) { + throw new InvalidArgumentException('Campaign not found.'); + } + $this->emit($output,['id'=>$id,'deleted'=>true]); + return self::SUCCESS; + } + if (!in_array($action,['create','update'],true)) { + throw new InvalidArgumentException('Use list, show, create, update, archive or delete.'); + } + $json=$input->getOption('json'); + $file=$input->getOption('file'); + if ($json!==null && $file!==null) { throw new InvalidArgumentException('Use either --json or --file.'); } + if ($file!==null) { + if (!is_file($file) || !is_readable($file) || filesize($file)>1048576) { + throw new InvalidArgumentException('JSON file not found, readable or below 1 MiB.'); + } + $json=file_get_contents($file); + } + if (!is_string($json) || $json==='') { throw new InvalidArgumentException('Provide --file or --json.'); } + $payload=json_decode($json,true,64,JSON_THROW_ON_ERROR); + if (!is_array($payload) || array_is_list($payload)) { + throw new InvalidArgumentException('Campaign payload must be a JSON object.'); + } + if ($action==='create') { + $actor=(string)($input->getOption('actor') ?? ''); + if ($actor==='' || $this->users->get($actor)===null) { + throw new InvalidArgumentException('Provide --actor with an existing Nextcloud user ID.'); + } + $result=$this->campaigns->create($payload,$actor); + } else { + $prior=$this->campaigns->find($id); + if ($prior===null) { throw new InvalidArgumentException('Campaign not found.'); } + $result=$this->campaigns->update($id,array_replace($prior,$payload)); + } + $this->emit($output,['id'=>$result['id'],'status'=>$result['status']]); + return self::SUCCESS; + } catch (\Throwable $error) { + $output->writeln(''.$error->getMessage().''); + return self::FAILURE; + } + } + private function emit(OutputInterface $output,array $payload):void { + $output->writeln(json_encode($payload,JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR)); + } +} diff --git a/lib/Command/PublishDue.php b/lib/Command/PublishDue.php new file mode 100644 index 0000000..eaee3d9 --- /dev/null +++ b/lib/Command/PublishDue.php @@ -0,0 +1,56 @@ +setName('communications:publish-due') + ->setDescription('Publish due board announcements and queue channel deliveries') + ->addOption('deliver',null,InputOption::VALUE_NONE, + 'Process up to 2000 accounts synchronously (for QA or repair)'); + } + protected function execute(InputInterface $input,OutputInterface $output):int { + try { + $result=$this->publisher->publishDue(500); + $output->writeln('Scheduled publications: '.json_encode($result,JSON_THROW_ON_ERROR)); + if (!$input->getOption('deliver')) { return self::SUCCESS; } + $processed=0; + foreach ($this->campaigns->all() as $c) { + $now=time(); + // The bulk worker is only for immediate/scheduled publications. Event + // campaigns use EventChannelDeliveryJob per trusted user and occurrence. + if (!in_array($c['trigger_key'],['nextcloud.board.published','nextcloud.time.window'],true) + || (!$c['board_visible'] && !$c['notify_users'] && !$c['notify_activity'] + && !$c['notify_email'] && $c['gateway_channels']===[]) + || $c['status']!=='active' || $c['published_at']<=0 || + ($c['starts_at']!==null && $c['starts_at']>$now) || + ($c['ends_at']!==null && $c['ends_at']<=$now)) { continue; } + $offset=0; + do { + $r=$this->publisher->broadcast((int)$c['id'],(int)$c['published_at'],$offset); + $processed += $r['processed']; + $offset += 100; + } while ($r['queued'] && $offset < 2000); + } + $output->writeln('Accounts processed: '.$processed); + return self::SUCCESS; + } catch (\Throwable $error) { + $output->writeln(''.$error->getMessage().''); + return self::FAILURE; + } + } +} diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php new file mode 100644 index 0000000..2d1077f --- /dev/null +++ b/lib/Controller/BoardController.php @@ -0,0 +1,100 @@ +session->getUser()?->getUID(); } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function index(int $page=1,string $search='',string $filter='all'):JSONResponse { + $uid=$this->uid(); + return $uid===null ? new JSONResponse(['error'=>'Sessão necessária.'],401) + : new JSONResponse($this->board->list($uid,$page,$search,$filter==='unread'?'unread':'all')); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function show(int $id):JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Sessão necessária.'],401); } + $item=$this->board->view($uid,$id); + return $item ? new JSONResponse(['announcement'=>$item]) + : new JSONResponse(['error'=>'Comunicado não encontrado.'],404); + } + + #[NoAdminRequired] + #[UseSession] + public function markRead(int $id):JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Sessão necessária.'],401); } + return $this->board->markRead($uid,$id) + ? new JSONResponse(['read'=>true]) + : new JSONResponse(['error'=>'Publicação não encontrada.'],404); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function comments(int $id,int $before=0):JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Sessão necessária.'],401); } + $items=$this->board->comments($uid,$id,max(0,$before)); + return $items ? new JSONResponse($items) + : new JSONResponse(['error'=>'Comunicado não encontrado.'],404); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function mentions(int $id,string $search=''):JSONResponse { + $uid=$this->uid(); + if($uid===null)return new JSONResponse(['error'=>'Sessão necessária.'],401); + $results=$this->board->mentionCandidates($uid,$id,$search); + return $results===null + ? new JSONResponse(['error'=>'Publicação indisponível.'],404) + : new JSONResponse(['users'=>$results]); + } + + #[NoAdminRequired] + #[UseSession] + public function createComment(int $id,string $message=''):JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Sessão necessária.'],401); } + try { + return new JSONResponse(['comment'=>$this->board->createComment($uid,$id,$message)],201); + } catch (InvalidArgumentException $e) { + return new JSONResponse(['error'=>$e->getMessage()],422); + } + } + + #[NoAdminRequired] + #[UseSession] + public function deleteComment(int $id,int $commentId):JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Sessão necessária.'],401); } + return $this->board->deleteComment($uid,$id,$commentId) + ? new JSONResponse(['deleted'=>true]) + : new JSONResponse(['error'=>'Comentário não encontrado ou indisponível.'],404); + } +} diff --git a/lib/Controller/CampaignController.php b/lib/Controller/CampaignController.php new file mode 100644 index 0000000..1367a2a --- /dev/null +++ b/lib/Controller/CampaignController.php @@ -0,0 +1,113 @@ +userSession->getUser()?->getUID(); } + + /** + * Diagnostic backwards-compatible list. No tab lease is taken here; production + * frontend uses POST /api/claim to ensure only one tab presents each delivery. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function pending(string $page = 'any', string $path = ''): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['campaigns'=>[]], 401); } + $this->deliveries->prepare($uid, $page, false, $path); + return new JSONResponse(['campaigns' => $this->deliveries->pending($uid, $page, 30, $path)]); + } + + #[NoAdminRequired] + #[UseSession] + public function claim(string $page = 'any', bool $newVisit = false, string $path = ''): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['error'=>'Sessão necessária.'], 401); } + $item = $this->deliveries->claim($uid, $page, $newVisit, $path); + return new JSONResponse(['campaign' => $item]); + } + + #[NoAdminRequired] + #[UseSession] + public function claimBatch(string $page='any',bool $newVisit=false,string $path='',int $limit=5):JSONResponse { + $uid=$this->uid(); + if ($uid===null) {return new JSONResponse(['error'=>'Sessão necessária.'],401);} + return new JSONResponse($this->deliveries->claimBatch($uid,$page,$newVisit,$path,max(1,min($limit,5)))); + } + + #[NoAdminRequired] + #[UseSession] + public function impression(int $deliveryId = 0, string $claimToken = ''): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['error'=>'Sessão necessária.'], 401); } + return $this->deliveries->transition($uid, $deliveryId, $claimToken, 'impression') + ? new JSONResponse(['recorded'=>true]) + : new JSONResponse(['error'=>'Entrega não encontrada ou reserva expirada.'], 409); + } + + #[NoAdminRequired] + #[UseSession] + public function acknowledge( + int $deliveryId = 0, + string $claimToken = '', + int $campaignId = 0, + string $eventId = '', + ): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['error'=>'Sessão necessária.'], 401); } + $ok = $deliveryId > 0 && $claimToken !== '' + ? $this->deliveries->transition($uid, $deliveryId, $claimToken, 'dismiss') + : ($campaignId > 0 && $eventId !== '' && $this->deliveries->legacyAcknowledge($uid, $campaignId, $eventId)); + return $ok ? new JSONResponse(['acknowledged' => true]) : new JSONResponse(['error'=>'Entrega inválida.'], 409); + } + + #[NoAdminRequired] + #[UseSession] + public function click(int $deliveryId = 0, string $claimToken = ''): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['error'=>'Sessão necessária.'], 401); } + return $this->deliveries->transition($uid, $deliveryId, $claimToken, 'click') + ? new JSONResponse(['recorded'=>true]) + : new JSONResponse(['error'=>'Entrega inválida.'], 409); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function preferences(): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['error'=>'Sessão necessária.'], 401); } + return new JSONResponse([ + 'sponsoredOptout' => $this->config->getUserValue($uid, 'communications', 'sponsored_optout', '0') === '1', + ]); + } + + #[NoAdminRequired] + #[UseSession] + public function setPreferences(bool $sponsoredOptout = false): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { return new JSONResponse(['error'=>'Sessão necessária.'], 401); } + $this->config->setUserValue($uid, 'communications', 'sponsored_optout', $sponsoredOptout ? '1':'0'); + return new JSONResponse(['sponsoredOptout'=>$sponsoredOptout]); + } +} diff --git a/lib/Controller/CampaignManagementController.php b/lib/Controller/CampaignManagementController.php new file mode 100644 index 0000000..961578c --- /dev/null +++ b/lib/Controller/CampaignManagementController.php @@ -0,0 +1,230 @@ +userSession->getUser()?->getUID(); } + private function denied():?JSONResponse { + return $this->editorAccess->canEdit($this->uid()) ? null + : new JSONResponse(['error'=>'Permissão editorial necessária.'],403); + } + + #[NoCSRFRequired] + #[UseSession] + #[NoAdminRequired] + public function index(): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + return new JSONResponse(['campaigns' => $this->campaigns->all()]); + } + + #[NoCSRFRequired] + #[UseSession] + #[NoAdminRequired] + public function show(int $id): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + $item = $this->campaigns->find($id); + return $item !== null ? new JSONResponse(['campaign' => $item]) : new JSONResponse(['error' => 'Campanha não encontrada.'], 404); + } + + #[NoCSRFRequired] + #[UseSession] + #[NoAdminRequired] + public function catalog(): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + return new JSONResponse([ + 'triggers' => $this->triggerCatalog->legacyTriggers(), + 'automation' => [ + 'intents' => $this->triggerCatalog->intents(), + 'sources' => $this->triggerCatalog->sources(), + 'placements' => $this->triggerCatalog->placements(), + 'applications' => $this->triggerCatalog->applications(), + ], + 'deliveryChannels' => $this->triggerCatalog->deliveryChannels(), + 'groups' => $this->campaigns->groupOptions(), + 'statuses' => CampaignRepository::STATUSES, + 'presentations' => CampaignRepository::PRESENTATIONS, + 'canConfigureEditors' => $this->editorAccess->isAdmin($this->uid()), + 'editorGroups' => $this->editorAccess->isAdmin($this->uid()) ? $this->editorAccess->groups() : [], + ]); + } + + #[NoCSRFRequired] + #[UseSession] + #[NoAdminRequired] + public function routes(string $app = ''): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + if (!preg_match('/^[a-z0-9_-]{2,32}$/', $app) || !$this->triggerCatalog->supportsPlacement($app) + || $app === 'any' || $app === 'settings') { + return new JSONResponse(['error'=>'Aplicativo sem páginas web disponíveis.'], 422); + } + return new JSONResponse(['app'=>$app, 'routes'=>$this->webRoutes->routesFor($app)]); + } + + #[NoCSRFRequired] + #[UseSession] + #[NoAdminRequired] + public function metrics(int $id): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + if ($this->campaigns->find($id) === null) { + return new JSONResponse(['error'=>'Campanha não encontrada.'], 404); + } + return new JSONResponse(['campaignId'=>$id, 'metrics'=>$this->deliveries->metrics($id), 'audited'=>false]); + } + + #[NoCSRFRequired] + #[UseSession] + #[NoAdminRequired] + public function users(string $search = ''): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + return new JSONResponse(['users' => $this->campaigns->userOptions($search)]); + } + + #[UseSession] + #[NoAdminRequired] + public function create(): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + try { + $uid = $this->userSession->getUser()?->getUID(); + if ($uid === null) { return new JSONResponse(['error' => 'Sessão necessária.'], 401); } + $campaign = $this->campaigns->create($this->request->getParams(), $uid); + return new JSONResponse(['campaign' => $campaign], 201); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error' => $error->getMessage()], 422); + } + } + + #[UseSession] + #[NoAdminRequired] + public function update(int $id): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + try { + $campaign = $this->campaigns->update($id, $this->request->getParams()); + return $campaign !== null ? new JSONResponse(['campaign' => $campaign]) : new JSONResponse(['error' => 'Campanha não encontrada.'], 404); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error' => $error->getMessage()], 422); + } + } + + #[UseSession] + #[NoAdminRequired] + public function status(int $id, string $status = ''): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + try { + $campaign = $this->campaigns->setStatus($id, $status); + return $campaign !== null ? new JSONResponse(['campaign' => $campaign]) : new JSONResponse(['error' => 'Campanha não encontrada.'], 404); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error' => $error->getMessage()], 422); + } + } + + #[UseSession] + #[NoAdminRequired] + public function duplicate(int $id): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + $original = $this->campaigns->find($id); + if ($original === null) { return new JSONResponse(['error' => 'Campanha não encontrada.'], 404); } + $original['name'] = mb_substr($original['name'], 0, 145) . ' (cópia)'; + $original['status'] = 'draft'; + // A scheduled campaign requires its start date even in draft mode. + // Preserve the original window on duplication so the copied campaign + // passes validation and the editor can present meaningful dates. + if ($original['trigger_key'] !== 'nextcloud.time.window') { + $original['starts_at'] = null; + $original['ends_at'] = null; + } + try { + $uid = $this->userSession->getUser()?->getUID(); + if ($uid === null) { return new JSONResponse(['error' => 'Sessão necessária.'], 401); } + return new JSONResponse(['campaign' => $this->campaigns->create($original, $uid)], 201); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error' => $error->getMessage()], 422); + } + } + + #[UseSession] + #[NoAdminRequired] + public function move(int $id,string $direction='',?int $target_id=null):JSONResponse { + if (($deny=$this->denied())!==null) {return $deny;} + try { + $items=$this->campaigns->move($id,$direction,$target_id); + return $items===null + ?new JSONResponse(['error'=>'Campanha não encontrada.'],404) + :new JSONResponse(['order'=>array_map(static fn(array $row):int=>(int)$row['id'],$items)]); + } catch (InvalidArgumentException $e) { + return new JSONResponse(['error'=>$e->getMessage()],422); + } + } + + #[UseSession] + #[NoAdminRequired] + public function archive(int $id): JSONResponse { + if (($deny = $this->denied()) !== null) { return $deny; } + $campaign = $this->campaigns->setStatus($id, 'archived'); + return $campaign !== null ? new JSONResponse(['campaign' => $campaign]) : new JSONResponse(['error' => 'Campanha não encontrada.'], 404); + } + + #[NoAdminRequired] + #[UseSession] + public function purge(int $id,string $confirmName=''):JSONResponse { + if (($deny=$this->denied())!==null) { return $deny; } + try { + return $this->campaigns->purgeArchived($id,$confirmName) + ? new JSONResponse(['deleted'=>true,'id'=>$id]) + : new JSONResponse(['error'=>'Publicação não encontrada.'],404); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error'=>$error->getMessage()],422); + } + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function editorGroups(): JSONResponse { + if (!$this->editorAccess->isAdmin($this->uid())) { + return new JSONResponse(['error'=>'Somente administradores podem consultar a delegação.'],403); + } + return new JSONResponse(['groups'=>$this->editorAccess->groups()]); + } + + #[NoAdminRequired] + #[UseSession] + public function setEditorGroups(): JSONResponse { + if (!$this->editorAccess->isAdmin($this->uid())) { + return new JSONResponse(['error'=>'Somente administradores podem delegar edição.'],403); + } + $groups = $this->request->getParam('groups',[]); + if (!is_array($groups)) { return new JSONResponse(['error'=>'Lista de grupos inválida.'],422); } + try { + return new JSONResponse(['groups'=>$this->editorAccess->configure($this->uid(),$groups)]); + } catch (InvalidArgumentException $error) { + return new JSONResponse(['error'=>$error->getMessage()],422); + } + } +} diff --git a/lib/Controller/GatewayPreferencesController.php b/lib/Controller/GatewayPreferencesController.php new file mode 100644 index 0000000..5755bf4 --- /dev/null +++ b/lib/Controller/GatewayPreferencesController.php @@ -0,0 +1,62 @@ +session->getUser()?->getUID(); } + + /** Only the logged-in user sees their own destinations. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function index(): JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Login necessário.'],401); } + $saved=$this->preferences->forUser($uid); + $channels=[]; + foreach (GatewayTransport::LABELS as $id=>$label) { + $channels[]=[ + 'id'=>$id,'label'=>$label, + 'available'=>$this->gateway->configured($id), + 'installed'=>$this->gateway->installed(), + 'enabled'=>isset($saved[$id]), + 'destination'=>$saved[$id]['destination']??'', + 'sponsored'=>$saved[$id]['sponsored']??false, + ]; + } + return new JSONResponse(['channels'=>$channels]); + } + + /** Full replacement: disabled channels lose their stored destinations. */ + #[NoAdminRequired] + #[UseSession] + public function update(array $channels=[]): JSONResponse { + $uid=$this->uid(); + if ($uid===null) { return new JSONResponse(['error'=>'Login necessário.'],401); } + try { + $this->preferences->replace($uid,$channels); + return $this->index(); + } catch (\InvalidArgumentException $error) { + return new JSONResponse(['error'=>$error->getMessage()],422); + } + } +} diff --git a/lib/Controller/GuestBannerController.php b/lib/Controller/GuestBannerController.php new file mode 100644 index 0000000..2e41997 --- /dev/null +++ b/lib/Controller/GuestBannerController.php @@ -0,0 +1,27 @@ +$this->campaigns->publicForGuests(time())]); + $r->addHeader('Cache-Control','private, no-store, max-age=0'); + $r->addHeader('X-Content-Type-Options','nosniff'); + return $r; + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php new file mode 100644 index 0000000..fd262da --- /dev/null +++ b/lib/Controller/PageController.php @@ -0,0 +1,43 @@ +renderHub(); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[UseSession] + public function board(): TemplateResponse { + return $this->renderHub(); + } + + private function renderHub(): TemplateResponse { + Util::addScript('communications', 'management'); + Util::addStyle('communications', 'management'); + return new TemplateResponse('communications','main',[ + 'canEdit'=>$this->editorAccess->canEdit($this->session->getUser()?->getUID()), + ]); + } +} diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php new file mode 100644 index 0000000..dede092 --- /dev/null +++ b/lib/Controller/WebhookController.php @@ -0,0 +1,86 @@ +config->getValueString('communications', 'webhook_secret', ''); + if (strlen($secret) < 32) { + return new JSONResponse(['error'=>'Webhook não configurado.'], 503); + } + $raw = file_get_contents('php://input'); + if (!is_string($raw) || strlen($raw) > 8192) { + return new JSONResponse(['error'=>'Conteúdo inválido.'], 400); + } + $timestamp = $this->request->getHeader('X-CC-Timestamp'); + $provided = $this->request->getHeader('X-CC-Signature'); + if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300 + || !preg_match('/^sha256=[a-f0-9]{64}$/', $provided)) { + return new JSONResponse(['error'=>'Assinatura inválida.'], 401); + } + $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret); + if (!hash_equals($expected, $provided)) { + return new JSONResponse(['error'=>'Assinatura inválida.'], 401); + } + try { + $payload = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + return new JSONResponse(['error'=>'JSON inválido.'], 422); + } + if (!is_array($payload)) { return new JSONResponse(['error'=>'Corpo inválido.'], 422); } + $uid = $payload['uid'] ?? null; + $externalEventId = $payload['eventId'] ?? null; + $campaignId = $payload['campaignId'] ?? null; + if (!is_string($uid) || $uid === '' || mb_strlen($uid) > 64 || $this->users->get($uid) === null + || !is_string($externalEventId) || $externalEventId === '' || strlen($externalEventId) > 128) { + return new JSONResponse(['error'=>'Destinatário ou evento inválido.'], 422); + } + if (filter_var($campaignId, FILTER_VALIDATE_INT, ['options'=>['min_range'=>1]]) === false) { + return new JSONResponse(['error'=>'Informe campaignId da campanha associada ao webhook.'],422); + } + $campaignId = (int)$campaignId; + $campaign = $this->campaigns->find($campaignId); + if ($campaign === null || $campaign['trigger_key'] !== 'external.webhook' + || $campaign['status'] !== 'active') { + return new JSONResponse(['error'=>'Campanha inexistente, inativa ou não configurada para webhooks.'],422); + } + try { + // One event belongs to exactly one campaign and one UID. Never broadcast + // one external event to unrelated campaigns that listen to the same trigger. + $event = $this->events->record($uid, 'external.webhook', 'campaign_' . $campaignId, + 'webhook:' . $campaignId . ':' . $externalEventId); + return new JSONResponse(['accepted'=>true, 'eventId'=>$event['event_uuid']], 202); + } catch (\Throwable $e) { + $this->logger->error('Contextual campaigns incoming webhook failure', ['exception'=>$e]); + return new JSONResponse(['error'=>'Não foi possível registrar o evento.'], 500); + } + } +} diff --git a/lib/Dashboard/BoardWidget.php b/lib/Dashboard/BoardWidget.php new file mode 100644 index 0000000..36114c1 --- /dev/null +++ b/lib/Dashboard/BoardWidget.php @@ -0,0 +1,52 @@ +urls->getAbsoluteURL($this->urls->imagePath('communications','app.svg')); + } + public function getUrl():?string { + return $this->urls->linkToRouteAbsolute('communications.page.board'); + } + public function load():void {} + public function getItemsV2(string $userId,?string $since=null,int $limit=7):WidgetItems { + $items=$this->board->list($userId,1)['announcements']; + $result=[]; + foreach (array_slice($items,0,min(10,max(1,$limit))) as $item) { + if ($since!==null && $item['publishedAt']<=(int)$since) { continue; } + $summary=preg_replace('/[\r\n\t#*_\[\]]+/', ' ',strip_tags($item['message'])); + $result[]=new WidgetItem( + $item['headline'], + mb_substr(trim($summary),0,140), + $this->urls->linkToRouteAbsolute('communications.page.board',['id'=>$item['id']]), + $this->urls->getAbsoluteURL($this->urls->imagePath('communications','app.svg')), + (string)$item['publishedAt'] + ); + } + return new WidgetItems($result,$result===[] ? 'Nenhum comunicado disponível para você.' : ''); + } + public function getWidgetButtons(string $userId):array { + return [new WidgetButton( + WidgetButton::TYPE_MORE, + $this->urls->linkToRouteAbsolute('communications.page.board'), + 'Abrir o mural' + )]; + } +} diff --git a/lib/Event/RegisterTriggerCapabilitiesEvent.php b/lib/Event/RegisterTriggerCapabilitiesEvent.php new file mode 100644 index 0000000..8271055 --- /dev/null +++ b/lib/Event/RegisterTriggerCapabilitiesEvent.php @@ -0,0 +1,24 @@ +sources) >= 100) { return; } + $this->sources[] = $source; + } + + public function getSources(): array { return $this->sources; } +} diff --git a/lib/Events/AnnouncementPublished.php b/lib/Events/AnnouncementPublished.php new file mode 100644 index 0000000..b0ca2be --- /dev/null +++ b/lib/Events/AnnouncementPublished.php @@ -0,0 +1,35 @@ +campaignId; } + public function getPublicationVersion(): int { return $this->publicationVersion; } + + public function getWebhookSerializable(): array { + return [ + 'campaignId' => $this->campaignId, + 'publicationId' => $this->campaignId . ':' . $this->publicationVersion, + 'subject' => $this->subject, + 'message' => $this->message, + 'plainMessage' => trim(preg_replace('/[\r\n]+/', "\n", strip_tags($this->message))), + 'scheduleTime' => $this->scheduleTime, + 'deleteTime' => $this->deleteTime, + ]; + } +} diff --git a/lib/Migration/Version1000Date20261107190000.php b/lib/Migration/Version1000Date20261107190000.php new file mode 100644 index 0000000..52c6508 --- /dev/null +++ b/lib/Migration/Version1000Date20261107190000.php @@ -0,0 +1,44 @@ +hasTable('cc_campaigns')) { + return null; + } + $table = $schema->createTable('cc_campaigns'); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('name', Types::STRING, ['length' => 160, 'notnull' => true]); + $table->addColumn('description', Types::TEXT, ['notnull' => true, 'default' => '']); + $table->addColumn('headline', Types::STRING, ['length' => 180, 'notnull' => true]); + $table->addColumn('message', Types::TEXT, ['notnull' => true]); + $table->addColumn('cta_label', Types::STRING, ['length' => 80, 'notnull' => true, 'default' => '']); + $table->addColumn('cta_url', Types::STRING, ['length' => 2000, 'notnull' => true, 'default' => '']); + $table->addColumn('trigger_key', Types::STRING, ['length' => 64, 'notnull' => true]); + $table->addColumn('audience_type', Types::STRING, ['length' => 20, 'notnull' => true, 'default' => 'all']); + $table->addColumn('audience_group', Types::STRING, ['length' => 64, 'notnull' => true, 'default' => '']); + $table->addColumn('presentation', Types::STRING, ['length' => 12, 'notnull' => true, 'default' => 'banner']); + $table->addColumn('status', Types::STRING, ['length' => 12, 'notnull' => true, 'default' => 'draft']); + $table->addColumn('priority', Types::INTEGER, ['notnull' => true, 'default' => 10]); + $table->addColumn('starts_at', Types::BIGINT, ['notnull' => false]); + $table->addColumn('ends_at', Types::BIGINT, ['notnull' => false]); + $table->addColumn('created_by', Types::STRING, ['length' => 64, 'notnull' => true]); + $table->addColumn('created_at', Types::BIGINT, ['notnull' => true]); + $table->addColumn('updated_at', Types::BIGINT, ['notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addIndex(['status', 'trigger_key'], 'cc_status_trigger'); + return $schema; + } +} diff --git a/lib/Migration/Version1001Date20261107210000.php b/lib/Migration/Version1001Date20261107210000.php new file mode 100644 index 0000000..6a69dab --- /dev/null +++ b/lib/Migration/Version1001Date20261107210000.php @@ -0,0 +1,32 @@ +hasTable('cc_campaigns')) { return null; } + $table = $schema->getTable('cc_campaigns'); + $changed = false; + if (!$table->hasColumn('audience_groups')) { + $table->addColumn('audience_groups', Types::TEXT, ['notnull' => true, 'default' => '[]']); + $changed = true; + } + if (!$table->hasColumn('audience_users')) { + $table->addColumn('audience_users', Types::TEXT, ['notnull' => true, 'default' => '[]']); + $changed = true; + } + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version1002Date20261107221500.php b/lib/Migration/Version1002Date20261107221500.php new file mode 100644 index 0000000..0513c63 --- /dev/null +++ b/lib/Migration/Version1002Date20261107221500.php @@ -0,0 +1,76 @@ +hasTable('cc_events')) { + $events = $schema->createTable('cc_events'); + $events->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $events->addColumn('event_uuid', Types::STRING, ['length' => 36, 'notnull' => true]); + $events->addColumn('dedupe_key', Types::STRING, ['length' => 64, 'notnull' => true]); + $events->addColumn('trigger_key', Types::STRING, ['length' => 64, 'notnull' => true]); + $events->addColumn('uid', Types::STRING, ['length' => 64, 'notnull' => true]); + $events->addColumn('context_app', Types::STRING, ['length' => 32, 'notnull' => true, 'default' => 'any']); + $events->addColumn('created_at', Types::BIGINT, ['notnull' => true]); + $events->setPrimaryKey(['id']); + $events->addUniqueIndex(['event_uuid'], 'cc_event_uuid'); + $events->addUniqueIndex(['dedupe_key'], 'cc_event_dedupe'); + $events->addIndex(['uid', 'created_at'], 'cc_event_uid_time'); + $changed = true; + } + if (!$schema->hasTable('cc_deliveries')) { + $delivery = $schema->createTable('cc_deliveries'); + $delivery->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $delivery->addColumn('event_id', Types::BIGINT, ['notnull' => true]); + $delivery->addColumn('campaign_id', Types::BIGINT, ['notnull' => true]); + $delivery->addColumn('uid', Types::STRING, ['length' => 64, 'notnull' => true]); + $delivery->addColumn('state', Types::STRING, ['length' => 16, 'notnull' => true, 'default' => 'pending']); + $delivery->addColumn('claim_token', Types::STRING, ['length' => 48, 'notnull' => true, 'default' => '']); + $delivery->addColumn('claim_until', Types::BIGINT, ['notnull' => true, 'default' => 0]); + $delivery->addColumn('created_at', Types::BIGINT, ['notnull' => true]); + $delivery->addColumn('impression_at', Types::BIGINT, ['notnull' => false]); + $delivery->addColumn('dismissed_at', Types::BIGINT, ['notnull' => false]); + $delivery->addColumn('clicked_at', Types::BIGINT, ['notnull' => false]); + $delivery->setPrimaryKey(['id']); + $delivery->addUniqueIndex(['event_id', 'campaign_id'], 'cc_delivery_event_campaign'); + $delivery->addIndex(['uid', 'state'], 'cc_delivery_uid_state'); + $delivery->addIndex(['campaign_id', 'uid', 'impression_at'], 'cc_delivery_frequency'); + $changed = true; + } + if ($schema->hasTable('cc_campaigns')) { + $campaign = $schema->getTable('cc_campaigns'); + $columns = [ + 'placement' => [Types::STRING, ['length' => 32, 'notnull' => true, 'default' => 'any']], + 'frequency' => [Types::STRING, ['length' => 12, 'notnull' => true, 'default' => 'event']], + 'min_interval_minutes' => [Types::INTEGER, ['notnull' => true, 'default' => 0]], + 'max_total' => [Types::INTEGER, ['notnull' => true, 'default' => 0]], + 'max_per_day' => [Types::INTEGER, ['notnull' => true, 'default' => 0]], + 'category' => [Types::STRING, ['length' => 16, 'notnull' => true, 'default' => 'institutional']], + 'group_match' => [Types::STRING, ['length' => 8, 'notnull' => true, 'default' => 'any']], + 'excluded_groups' => [Types::TEXT, ['notnull' => true, 'default' => '[]']], + 'theme' => [Types::STRING, ['length' => 12, 'notnull' => true, 'default' => 'default']], + ]; + foreach ($columns as $name => [$type, $opts]) { + if (!$campaign->hasColumn($name)) { + $campaign->addColumn($name, $type, $opts); + $changed = true; + } + } + } + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version1003Date20261107223000.php b/lib/Migration/Version1003Date20261107223000.php new file mode 100644 index 0000000..94226c3 --- /dev/null +++ b/lib/Migration/Version1003Date20261107223000.php @@ -0,0 +1,28 @@ +hasTable('cc_leases')) { return null; } + $table = $schema->createTable('cc_leases'); + $table->addColumn('id', Types::BIGINT, ['autoincrement'=>true, 'notnull'=>true]); + $table->addColumn('uid', Types::STRING, ['length'=>64, 'notnull'=>true]); + $table->addColumn('campaign_id', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('delivery_id', Types::BIGINT, ['notnull'=>true, 'default'=>0]); + $table->addColumn('token', Types::STRING, ['length'=>48, 'notnull'=>true, 'default'=>'']); + $table->addColumn('expires_at', Types::BIGINT, ['notnull'=>true, 'default'=>0]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uid','campaign_id'], 'cc_lease_uid_campaign'); + return $schema; + } +} diff --git a/lib/Migration/Version1004Date20261107230000.php b/lib/Migration/Version1004Date20261107230000.php new file mode 100644 index 0000000..30a0db2 --- /dev/null +++ b/lib/Migration/Version1004Date20261107230000.php @@ -0,0 +1,40 @@ +hasTable('cc_campaigns')) { return null; } + $table = $schema->getTable('cc_campaigns'); + if ($table->hasColumn('published_at')) { return null; } + $table->addColumn('published_at', Types::BIGINT, ['notnull'=>true,'default'=>0]); + return $schema; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $now = time(); + $qb = $this->db->getQueryBuilder(); + $qb->update('cc_campaigns') + ->set('published_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('status', $qb->createNamedParameter('active'))) + ->andWhere($qb->expr()->eq('published_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } +} diff --git a/lib/Migration/Version1005Date20261108105000.php b/lib/Migration/Version1005Date20261108105000.php new file mode 100644 index 0000000..6189ff4 --- /dev/null +++ b/lib/Migration/Version1005Date20261108105000.php @@ -0,0 +1,26 @@ +hasTable('cc_campaigns')) { return null; } + $table = $schema->getTable('cc_campaigns'); + if ($table->hasColumn('page_route')) { return null; } + $table->addColumn('page_route', Types::STRING, ['length'=>255, 'notnull'=>true, 'default'=>'*']); + return $schema; + } +} diff --git a/lib/Migration/Version1006Date20261108170000.php b/lib/Migration/Version1006Date20261108170000.php new file mode 100644 index 0000000..df3bcc0 --- /dev/null +++ b/lib/Migration/Version1006Date20261108170000.php @@ -0,0 +1,23 @@ +hasTable('cc_campaigns')) { return null; } + $table = $schema->getTable('cc_campaigns'); + if ($table->hasColumn('page_filters')) { return null; } + $table->addColumn('page_filters', Types::TEXT, ['notnull'=>true,'default'=>'{}']); + return $schema; + } +} diff --git a/lib/Migration/Version1007Date20261108190600.php b/lib/Migration/Version1007Date20261108190600.php new file mode 100644 index 0000000..ecafc61 --- /dev/null +++ b/lib/Migration/Version1007Date20261108190600.php @@ -0,0 +1,34 @@ +hasTable('cc_campaigns')) { return null; } + $table = $schema->getTable('cc_campaigns'); + $changed = false; + if (!$table->hasColumn('banner_color')) { + $table->addColumn('banner_color', Types::STRING, [ + 'length' => 7, 'notnull' => true, 'default' => '#6748AC' + ]); + $changed = true; + } + if (!$table->hasColumn('banner_icon')) { + $table->addColumn('banner_icon', Types::STRING, [ + 'length' => 12, 'notnull' => true, 'default' => 'auto' + ]); + $changed = true; + } + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version1008Date20261108204500.php b/lib/Migration/Version1008Date20261108204500.php new file mode 100644 index 0000000..94dc1e3 --- /dev/null +++ b/lib/Migration/Version1008Date20261108204500.php @@ -0,0 +1,52 @@ +hasTable('cc_campaigns')) { + $campaign = $schema->getTable('cc_campaigns'); + $fields = [ + 'board_visible' => [Types::SMALLINT, ['notnull'=>true,'default'=>0]], + 'comments_enabled' => [Types::SMALLINT, ['notnull'=>true,'default'=>1]], + 'dismissible' => [Types::SMALLINT, ['notnull'=>true,'default'=>1]], + 'text_align' => [Types::STRING, ['length'=>8,'notnull'=>true,'default'=>'left']], + 'text_color' => [Types::STRING, ['length'=>7,'notnull'=>false]], + 'manual_order' => [Types::INTEGER, ['notnull'=>true,'default'=>0]], + 'translated_content' => [Types::TEXT, ['notnull'=>true,'default'=>'{}']], + ]; + foreach ($fields as $name => [$type, $options]) { + if (!$campaign->hasColumn($name)) { + $campaign->addColumn($name, $type, $options); + $changed = true; + } + } + } + if (!$schema->hasTable('cc_board_comments')) { + $comments = $schema->createTable('cc_board_comments'); + $comments->addColumn('id', Types::BIGINT, ['autoincrement'=>true,'notnull'=>true]); + $comments->addColumn('campaign_id', Types::BIGINT, ['notnull'=>true]); + $comments->addColumn('uid', Types::STRING, ['length'=>64,'notnull'=>true]); + $comments->addColumn('message', Types::TEXT, ['notnull'=>true]); + $comments->addColumn('created_at', Types::BIGINT, ['notnull'=>true]); + $comments->addColumn('updated_at', Types::BIGINT, ['notnull'=>true]); + $comments->addColumn('deleted_at', Types::BIGINT, ['notnull'=>false]); + $comments->setPrimaryKey(['id']); + $comments->addIndex(['campaign_id','id'], 'cc_board_comments_campaign'); + $comments->addIndex(['uid','created_at'], 'cc_board_comments_uid'); + $changed = true; + } + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version1009Date20261108213000.php b/lib/Migration/Version1009Date20261108213000.php new file mode 100644 index 0000000..de8a3f0 --- /dev/null +++ b/lib/Migration/Version1009Date20261108213000.php @@ -0,0 +1,62 @@ +hasTable('cc_campaigns')) { + $campaign = $schema->getTable('cc_campaigns'); + foreach (['notify_users','notify_activity','notify_email'] as $name) { + if (!$campaign->hasColumn($name)) { + $campaign->addColumn($name, Types::SMALLINT, ['notnull'=>true,'default'=>0]); + $changed = true; + } + } + } + if (!$schema->hasTable('cc_board_pub')) { + $table = $schema->createTable('cc_board_pub'); + $table->addColumn('id', Types::BIGINT, ['autoincrement'=>true,'notnull'=>true]); + $table->addColumn('campaign_id', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('version', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('event_sent', Types::SMALLINT, ['notnull'=>true,'default'=>0]); + $table->addColumn('broadcast_done', Types::SMALLINT, ['notnull'=>true,'default'=>0]); + $table->addColumn('created_at', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('event_sent_at', Types::BIGINT, ['notnull'=>false]); + $table->addColumn('last_error', Types::STRING, ['length'=>250,'notnull'=>false]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['campaign_id','version'],'cc_board_pub_unique'); + $table->addIndex(['event_sent','created_at'],'cc_board_pub_event'); + $changed = true; + } + if (!$schema->hasTable('cc_board_sent')) { + $table = $schema->createTable('cc_board_sent'); + $table->addColumn('id', Types::BIGINT, ['autoincrement'=>true,'notnull'=>true]); + $table->addColumn('campaign_id', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('version', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('uid', Types::STRING, ['length'=>64,'notnull'=>true]); + $table->addColumn('channel', Types::STRING, ['length'=>16,'notnull'=>true]); + $table->addColumn('status', Types::STRING, ['length'=>12,'notnull'=>true,'default'=>'pending']); + $table->addColumn('created_at', Types::BIGINT, ['notnull'=>true]); + $table->addColumn('sent_at', Types::BIGINT, ['notnull'=>false]); + $table->addColumn('attempts', Types::INTEGER, ['notnull'=>true,'default'=>0]); + $table->addColumn('claim_until', Types::BIGINT, ['notnull'=>true,'default'=>0]); + $table->addColumn('last_error', Types::STRING, ['length'=>250,'notnull'=>false]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['campaign_id','version','uid','channel'],'cc_board_sent_unique'); + $table->addIndex(['status','claim_until'],'cc_board_sent_claim'); + $changed = true; + } + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version1010Date20261108220000.php b/lib/Migration/Version1010Date20261108220000.php new file mode 100644 index 0000000..ca09fd0 --- /dev/null +++ b/lib/Migration/Version1010Date20261108220000.php @@ -0,0 +1,30 @@ +hasTable('cc_campaigns')) { return null; } + $table=$schema->getTable('cc_campaigns'); + $changed=false; + if (!$table->hasColumn('app_target_mode')) { + $table->addColumn('app_target_mode',Types::STRING,['notnull'=>true,'length'=>12,'default'=>'legacy']); + $changed=true; + } + if (!$table->hasColumn('app_target_apps')) { + $table->addColumn('app_target_apps',Types::TEXT,['notnull'=>true,'default'=>'[]']); + $changed=true; + } + return $changed?$schema:null; + } +} diff --git a/lib/Migration/Version1011Date20261108225500.php b/lib/Migration/Version1011Date20261108225500.php new file mode 100644 index 0000000..e354091 --- /dev/null +++ b/lib/Migration/Version1011Date20261108225500.php @@ -0,0 +1,25 @@ +hasTable('cc_inbox_reads')) { return null; } + $table=$schema->createTable('cc_inbox_reads'); + $table->addColumn('uid',Types::STRING,['length'=>64,'notnull'=>true]); + $table->addColumn('campaign_id',Types::BIGINT,['notnull'=>true]); + $table->addColumn('read_at',Types::BIGINT,['notnull'=>true]); + $table->setPrimaryKey(['uid','campaign_id']); + return $schema; + } +} diff --git a/lib/Migration/Version1012Date20261108234000.php b/lib/Migration/Version1012Date20261108234000.php new file mode 100644 index 0000000..7bcf42a --- /dev/null +++ b/lib/Migration/Version1012Date20261108234000.php @@ -0,0 +1,22 @@ +hasTable('cc_campaigns'))return null; + $table=$schema->getTable('cc_campaigns'); + if($table->hasColumn('excluded_match'))return null; + $table->addColumn('excluded_match',Types::STRING,['length'=>8,'notnull'=>true,'default'=>'any']); + return $schema; + } +} diff --git a/lib/Migration/Version1013Date20261108235500.php b/lib/Migration/Version1013Date20261108235500.php new file mode 100644 index 0000000..dbc354d --- /dev/null +++ b/lib/Migration/Version1013Date20261108235500.php @@ -0,0 +1,23 @@ +hasTable('cc_campaigns'))return null; + $table=$schema->getTable('cc_campaigns'); + if($table->hasColumn('guest_visible'))return null; + $table->addColumn('guest_visible',Types::SMALLINT,['notnull'=>true,'default'=>0]); + $table->addIndex(['guest_visible','status'],'cc_guest_status'); + return $schema; + } +} diff --git a/lib/Migration/Version1014Date20261109000500.php b/lib/Migration/Version1014Date20261109000500.php new file mode 100644 index 0000000..1013292 --- /dev/null +++ b/lib/Migration/Version1014Date20261109000500.php @@ -0,0 +1,21 @@ +hasTable('cc_campaigns'))return null; + $table=$schema->getTable('cc_campaigns'); + if($table->hasColumn('banner_color'))$table->changeColumn('banner_color',['length'=>9]); + if($table->hasColumn('text_color'))$table->changeColumn('text_color',['length'=>9]); + return $schema; + } +} diff --git a/lib/Migration/Version1015Date20261109134000.php b/lib/Migration/Version1015Date20261109134000.php new file mode 100644 index 0000000..a72194a --- /dev/null +++ b/lib/Migration/Version1015Date20261109134000.php @@ -0,0 +1,34 @@ +hasTable('cc_campaigns')) { + $table=$schema->getTable('cc_campaigns'); + if (!$table->hasColumn('gateway_channels')) { + $table->addColumn('gateway_channels',Types::TEXT,['notnull'=>true,'default'=>'[]']); + $changed=true; + } + } + if ($schema->hasTable('cc_board_sent')) { + $table=$schema->getTable('cc_board_sent'); + if ($table->hasColumn('channel') && $table->getColumn('channel')->getLength()<32) { + $table->changeColumn('channel',['length'=>32]); + $changed=true; + } + } + return $changed?$schema:null; + } +} diff --git a/lib/Migration/Version1016Date20261109150000.php b/lib/Migration/Version1016Date20261109150000.php new file mode 100644 index 0000000..a5ee1a9 --- /dev/null +++ b/lib/Migration/Version1016Date20261109150000.php @@ -0,0 +1,35 @@ +hasTable('cc_board_sent')) { return null; } + $table=$schema->getTable('cc_board_sent'); + if ($table->hasColumn('event_id')) { return null; } + $table->addColumn('event_id',Types::BIGINT,['notnull'=>true,'default'=>0]); + if ($table->hasIndex('cc_board_sent_unique')) { + $table->dropIndex('cc_board_sent_unique'); + } + $table->addUniqueIndex( + ['campaign_id','version','event_id','uid','channel'], + 'cc_sent_occ_unique' + ); + $table->addIndex(['event_id','status','claim_until'],'cc_sent_event_retry'); + $table->addIndex(['uid','channel','status'],'cc_sent_user_channel'); + return $schema; + } +} diff --git a/lib/Notification/BoardNotifier.php b/lib/Notification/BoardNotifier.php new file mode 100644 index 0000000..32f57cb --- /dev/null +++ b/lib/Notification/BoardNotifier.php @@ -0,0 +1,53 @@ +getApp() !== 'communications') { + throw new UnknownNotificationException(); + } + $subject=$notification->getSubject(); + $type=$notification->getObjectType(); + if ($subject==='mention' && $type==='cc_board_comment') { + $item=$this->board->notificationAllowed($notification->getUser(),(int)$notification->getObjectId()); + if ($item===null) { throw new AlreadyProcessedException(); } + $author=$this->users->getDisplayName($item['actor']) ?: $item['actor']; + $notification->setParsedSubject($author.' mencionou você no mural de comunicados') + ->setParsedMessage('Abra o comunicado para ler e responder.'); + $id=$item['campaignId']; + } elseif ($subject==='published' && $type==='cc_board_announcement') { + $id=(int)$notification->getObjectId(); + $item=$this->board->view($notification->getUser(),$id); + if ($item===null) { throw new AlreadyProcessedException(); } + $notification->setParsedSubject('Novo comunicado: '.$item['headline']) + ->setParsedMessage(mb_substr(trim(strip_tags($item['message'])),0,350)); + } else { + throw new UnknownNotificationException(); + } + + return $notification + ->setLink(PublicationLink::absolute($this->urls,$id)) + ->setIcon($this->urls->getAbsoluteURL($this->urls->imagePath('communications','app.svg'))); + } +} diff --git a/lib/Service/BoardPublicationService.php b/lib/Service/BoardPublicationService.php new file mode 100644 index 0000000..79db4ce --- /dev/null +++ b/lib/Service/BoardPublicationService.php @@ -0,0 +1,471 @@ + 0 + && ($c['starts_at'] === null || $c['starts_at'] <= $now) + && ($c['ends_at'] === null || $c['ends_at'] > $now); + } + + /** @return array|null */ + private function publication(int $id, int $version): ?array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*')->from(self::PUB) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $row = $result->fetchAssociative(); + $result->closeCursor(); + return $row === false ? null : $row; + } + + private function ensurePublication(int $id, int $version): array { + $existing = $this->publication($id,$version); + if ($existing !== null) { return $existing; } + $qb = $this->db->getQueryBuilder(); + try { + $qb->insert(self::PUB)->values([ + 'campaign_id'=>$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT), + 'version'=>$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT), + 'created_at'=>$qb->createNamedParameter(time(),IQueryBuilder::PARAM_INT), + ])->executeStatement(); + } catch (\OCP\DB\Exception $e) { + // A second worker may have inserted the same publication meanwhile. + if ($this->publication($id,$version) === null) { throw $e; } + } + return $this->publication($id,$version); + } + + private function markPublication(int $id,int $version,string $field,int $value):void { + if (!in_array($field,['event_sent','broadcast_done'],true)) { + throw new \InvalidArgumentException('Invalid publication field'); + } + $qb=$this->db->getQueryBuilder(); + $qb->update(self::PUB) + ->set($field,$qb->createNamedParameter($value,IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT))); + if ($field === 'event_sent') { + $qb->set('event_sent_at',$qb->createNamedParameter(time(),IQueryBuilder::PARAM_INT)); + } + $qb->executeStatement(); + } + + /** + * Called by cron (or a local occ smoke command), never from a browser. + * A scheduled announcement emits the public event only after its start time. + */ + public function publishDue(int $limit=100):array { + $limit=max(1,min($limit,500)); + $stats=['events'=>0,'enqueued'=>0,'errors'=>0]; + $now=time(); + foreach ($this->campaigns->all() as $campaign) { + if (!$this->due($campaign,$now)) { continue; } + if ($stats['events']+$stats['enqueued'] >= $limit*2) { break; } + $id=(int)$campaign['id']; + $version=(int)$campaign['published_at']; + try { + $state=$this->ensurePublication($id,$version); + if ((int)$state['event_sent'] !== 1) { + // Announce an inbox publication only if one exists; channel-only sends + // do not create a false webhook/event announcing a board entry. + if ($campaign['board_visible']) { + $event=new AnnouncementPublished( + $id,$version,$campaign['headline'],$campaign['message'], + $campaign['starts_at'],$campaign['ends_at'] + ); + $this->dispatcher->dispatchTyped($event); + $stats['events']++; + } + $this->markPublication($id,$version,'event_sent',1); + } + if ((int)$state['broadcast_done'] === 1) { continue; } + if (!$campaign['notify_users'] && !$campaign['notify_activity'] && !$campaign['notify_email'] + && $campaign['gateway_channels']===[]) { + $this->markPublication($id,$version,'broadcast_done',1); + continue; + } + $this->jobs->add(BoardBroadcastJob::class,['id'=>$id,'version'=>$version,'offset'=>0]); + $stats['enqueued']++; + } catch (\Throwable $e) { + $stats['errors']++; + $this->logger->error('Failed to publish contextual announcement '.$id, + ['app'=>self::APP,'exception'=>$e]); + } + } + return $stats; + } + + private function sentRow(int $id,int $version,string $uid,string $channel,int $eventId=0): ?array { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('*')->from(self::SENT) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('channel',$qb->createNamedParameter($channel))) + ->andWhere($qb->expr()->eq('event_id',$qb->createNamedParameter($eventId,IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $row=$rs->fetchAssociative();$rs->closeCursor(); + return $row === false ? null : $row; + } + + private function claim(int $id,int $version,string $uid,string $channel,int $eventId=0):?array { + $now=time(); + $row=$this->sentRow($id,$version,$uid,$channel,$eventId); + if ($row===null) { + $qb=$this->db->getQueryBuilder(); + try { + $qb->insert(self::SENT)->values([ + 'campaign_id'=>$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT), + 'version'=>$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT), + 'event_id'=>$qb->createNamedParameter($eventId,IQueryBuilder::PARAM_INT), + 'uid'=>$qb->createNamedParameter($uid), + 'channel'=>$qb->createNamedParameter($channel), + 'status'=>$qb->createNamedParameter('pending'), + 'created_at'=>$qb->createNamedParameter($now,IQueryBuilder::PARAM_INT), + ])->executeStatement(); + } catch (\OCP\DB\Exception $e) { + if ($this->sentRow($id,$version,$uid,$channel,$eventId)===null) { throw $e; } + } + $row=$this->sentRow($id,$version,$uid,$channel,$eventId); + } + if ($row===null || in_array($row['status'],['sent','skipped','failed','unknown'],true) + || (int)$row['attempts']>=5 || (int)$row['claim_until']>$now) { return null; } + // If a worker died after contacting SMTP or an external provider, retrying + // its expired claim could send a duplicate. Preserve unknown for review. + if ($row['status']==='claimed' && ($channel==='email' || str_starts_with($channel,'gateway:'))) { + $this->state((int)$row['id'],'unknown','submission_outcome_unknown'); + return null; + } + $qb=$this->db->getQueryBuilder(); + $changed=$qb->update(self::SENT) + ->set('status',$qb->createNamedParameter('claimed')) + ->set('attempts',$qb->createNamedParameter(((int)$row['attempts'])+1,IQueryBuilder::PARAM_INT)) + ->set('claim_until',$qb->createNamedParameter($now+90,IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id',$qb->createNamedParameter((int)$row['id'],IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->lte('claim_until',$qb->createNamedParameter($now,IQueryBuilder::PARAM_INT))) + ->executeStatement(); + return $changed===1 ? $row : null; + } + + private function state(int $rowId,string $status,?string $error=null,int $backoff=0):void { + $qb=$this->db->getQueryBuilder(); + $qb->update(self::SENT) + ->set('status',$qb->createNamedParameter($status)) + ->set('claim_until',$qb->createNamedParameter($backoff?time()+$backoff:0,IQueryBuilder::PARAM_INT)) + ->set('last_error',$qb->createNamedParameter($error===null?null:mb_substr($error,0,248))); + if ($status==='sent') { + $qb->set('sent_at',$qb->createNamedParameter(time(),IQueryBuilder::PARAM_INT)); + } + $qb->where($qb->expr()->eq('id',$qb->createNamedParameter($rowId,IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + private function deliver(array $c,IUser $user,int $version,string $channel,int $eventId=0):void { + $uid=$user->getUID(); + $id=(int)$c['id']; + $row=$this->claim($id,$version,$uid,$channel,$eventId); + if ($row===null) { return; } + try { + $sent=match($channel) { + 'notifications' => $this->notify($c,$user), + 'activity' => $this->activity($c,$user,$version), + 'email' => $this->email($c,$user), + 'inbox' => true, + default => str_starts_with($channel,'gateway:') + ? $this->gateway($c,$user,substr($channel,8)) : false, + }; + $this->state((int)$row['id'],$sent?'sent':'skipped'); + } catch (DeliveryOutcomeUnknownException $e) { + // SMTP delivery follows the same rule: an ambiguous + // submission never retries automatically without provider idempotency. + $this->state((int)$row['id'],'unknown','submission_outcome_unknown'); + $this->logger->warning('Delivery submission outcome unknown',[ + 'app'=>self::APP,'campaignId'=>$id,'channel'=>$channel, + 'exceptionClass'=>$e::class, + ]); + } catch (\Throwable $e) { + $attempt=(int)$row['attempts']+1; + // External provider exceptions may include a phone, chat ID or message: + // neither persist their text nor attach them as logger exceptions. + $this->state((int)$row['id'],$attempt>=5?'failed':'retry', + $e::class,min(300,30*$attempt)); + $this->logger->warning('Contextual announcement channel delivery failed', + ['app'=>self::APP,'campaignId'=>$id,'channel'=>$channel,'exceptionClass'=>$e::class]); + } + } + + private function notify(array $c,IUser $user):bool { + if (!$this->apps->isEnabledForUser('notifications',$user)) { return false; } + $n=$this->notifications->createNotification(); + $n->setApp(self::APP)->setUser($user->getUID()) + ->setDateTime(new \DateTime()) + ->setObject('cc_board_announcement',(string)$c['id']) + ->setSubject('published',[$c['created_by']]); + $this->notifications->notify($n); + return true; + } + + private function activity(array $c,IUser $user,int $version):bool { + if (!$this->apps->isEnabledForUser('activity',$user)) { return false; } + $event=$this->activity->generateEvent(); + $event->setApp(self::APP) + ->setType(self::APP) + ->setAffectedUser($user->getUID()) + ->setAuthor($c['created_by']) + ->setTimestamp($version) + ->setSubject('board_published',[(string)$c['id']]) + ->setObject('cc_board_announcement',(int)$c['id']); + $this->activity->publish($event); + return true; + } + + /** URL generation in cron can lack registered app routes; never send a root link. */ + private function inboxLink(int $id):string { + return PublicationLink::absolute($this->urls,$id); + } + + private function email(array $c,IUser $user):bool { + $address=$user->getEMailAddress(); + if (!is_string($address) || !filter_var($address,FILTER_VALIDATE_EMAIL)) { return false; } + $email=$this->mailer->createMessage(); + $email->setTo([$address=>$user->getDisplayName()]); + $localized=CampaignRepository::localize($c,$this->config->getUserValue($user->getUID(),'core','lang','')); + $email->setSubject($localized['headline']); + $email->setPlainBody($localized['message']."\n\n".$this->inboxLink((int)$c['id'])); + // IMailer reports failed recipients even when no exception was raised. + // An e-mail-only publication must not be marked as successfully delivered. + try { + $failures=$this->mailer->send($email); + } catch (\Throwable $error) { + throw new DeliveryOutcomeUnknownException('SMTP outcome unknown',0,$error); + } + if ($failures!==[]) { + throw new \RuntimeException('Mail transport rejected one or more recipients'); + } + return true; + } + + /** Send only when the recipient personally opted in for this gateway. */ + private function gateway(array $c,IUser $user,string $id):bool { + if (!in_array($id,$c['gateway_channels'],true)) { return false; } + $destination=$this->gatewayPreferences->destinationFor( + $user->getUID(),$id,$c['category'] + ); + if ($destination===null) { return false; } + $localized=CampaignRepository::localize( + $c,$this->config->getUserValue($user->getUID(),'core','lang','') + ); + $body=$localized['headline']."\n".$localized['message']."\n\n" + .$this->inboxLink((int)$c['id']); + // Bound body size; no media, HTML or secrets from the document/event. + $this->gatewayTransport->send($id,$destination,mb_substr($body,0,5000)); + return true; + } + + /** Each job handles only 100 accounts and then queues its successor. */ + public function broadcast(int $id,int $version,int $offset=0):array { + $status=['processed'=>0,'queued'=>false,'done'=>false]; + $c=$this->campaigns->find($id); + $offset=max(0,$offset); + if ($c===null || (int)$c['published_at']!==$version || !$this->due($c,time())) { + $this->markPublication($id,$version,'broadcast_done',1); + return $status; + } + $accounts=$this->users->search('',self::BATCH,$offset); + foreach ($accounts as $user) { + if (!$user->isEnabled() || !$this->campaigns->isEligible($c,$user->getUID(),time())) { continue; } + if ($c['category']==='sponsored' + && $this->config->getUserValue($user->getUID(),self::APP,'sponsored_optout','0')==='1') { continue; } + foreach (['notify_users'=>'notifications','notify_activity'=>'activity','notify_email'=>'email'] as $key=>$channel) { + if (!$c[$key]) { continue; } + if ($user->getUID()===$c['created_by'] && $channel!=='activity') { continue; } + $this->deliver($c,$user,$version,$channel); + } + // A creator can receive these only through their own opt-in. + foreach ($c['gateway_channels'] as $gatewayId) { + if (!isset(GatewayTransport::LABELS[$gatewayId])) { continue; } + if ($this->gatewayPreferences->destinationFor($user->getUID(),$gatewayId,$c['category'])===null) { continue; } + $this->deliver($c,$user,$version,'gateway:'.$gatewayId); + } + $status['processed']++; + } + if (count($accounts)===self::BATCH) { + $this->jobs->add(BoardBroadcastJob::class, + ['id'=>$id,'version'=>$version,'offset'=>$offset+self::BATCH]); + $status['queued']=true; + } else { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select($qb->func()->count('id'))->from(self::SENT) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('status',$qb->createNamedParameter(['retry','claimed','pending'],IQueryBuilder::PARAM_STR_ARRAY))) + ->executeQuery(); + $pending=(int)$rs->fetchOne();$rs->closeCursor(); + if ($pending===0) { + $this->markPublication($id,$version,'broadcast_done',1); + $status['done']=true; + } + } + return $status; + } + + /** + * A recorded event has ONE trusted subject. Never fan out to all users. + * Webhook/Flow/page events are tied to a specific campaign by context_app. + * Revalidate audience, lifecycle, sponsored opt-out and timing at dispatch. + */ + public function dispatchEvent(int $eventId):array { + $result=['matched'=>0,'channels'=>0]; + $event=$this->events->byId($eventId); + if ($event===null || in_array($event['trigger_key'],[ + 'nextcloud.board.published','nextcloud.time.window'],true)) { return $result; } + $uid=$event['uid']; + $user=$this->users->get($uid); + if ($user===null || !$user->isEnabled()) { return $result; } + $now=time(); + foreach ($this->campaigns->activeForTrigger($event['trigger_key']) as $campaign) { + $id=(int)$campaign['id']; + $version=(int)$campaign['published_at']; + if ($version<=0 || $event['created_at']=(int)$campaign['ends_at']) + || !$this->campaigns->isEligible($campaign,$uid,$now)) { continue; } + if (in_array($event['trigger_key'],['external.webhook','nextcloud.flow.matched','nextcloud.page.opened'],true) + && $event['context_app']!=='campaign_'.$id) { continue; } + if ($campaign['category']==='sponsored' + && $this->config->getUserValue($uid,self::APP,'sponsored_optout','0')==='1') { continue; } + $result['matched']++; + $channels=[]; + if ($campaign['board_visible']) { $channels[]='inbox'; } + if ($campaign['notify_users'] && $uid!==$campaign['created_by']) { $channels[]='notifications'; } + if ($campaign['notify_activity']) { $channels[]='activity'; } + if ($campaign['notify_email'] && $uid!==$campaign['created_by']) { $channels[]='email'; } + foreach ($campaign['gateway_channels'] as $idOfGateway) { + if (isset(GatewayTransport::LABELS[$idOfGateway]) && + $this->gatewayPreferences->destinationFor($uid,$idOfGateway,$campaign['category'])!==null) { + $channels[]='gateway:'.$idOfGateway; + } + } + foreach ($channels as $channel) { + if (!$this->allowedForEvent($campaign,$uid,$channel,$now,$eventId)) { continue; } + $this->deliver($campaign,$user,$version,$channel,$eventId); + $result['channels']++; + } + } + return $result; + } + + /** + * The same per-recipient frequency applies to every channel. Only accepted, + * currently claimed or retryable messages consume a send slot. + */ + private function allowedForEvent(array $campaign,string $uid,string $channel,int $now,int $eventId):bool { + $frequency=$campaign['frequency']; + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('event_id','created_at','sent_at','status')->from(self::SENT) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter((int)$campaign['id'],IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter((int)$campaign['published_at'],IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('channel',$qb->createNamedParameter($channel))) + ->andWhere($qb->expr()->gt('event_id',$qb->createNamedParameter(0,IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $prior=$rs->fetchAllAssociative();$rs->closeCursor(); + $times=[]; + foreach ($prior as $row) { + if ((int)$row['event_id']===$eventId) { return true; } + if (in_array($row['status'],['sent','claimed','retry','pending','unknown'],true)) { + $times[]=(int)($row['sent_at']??$row['created_at']); + } + } + if ($campaign['max_total']>0 && count($times)>=$campaign['max_total']) {return false;} + if ($campaign['max_per_day']>0 && + count(array_filter($times,static fn(int $stamp):bool=>$stamp>=$now-86400))>=$campaign['max_per_day']) { + return false; + } + if ($times===[]) {return true;} + $minimum=match($frequency) { + 'once'=>PHP_INT_MAX, + 'daily'=>86400,'weekly'=>604800, + 'custom'=>$campaign['min_interval_minutes']*60, + default=>0, + }; + return $now-max($times)>=$minimum; + } + + /** Timed cron: retry only submissions known safe to repeat. Unknown is final. */ + public function retryEventDeliveries(int $limit=100):int { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('event_id')->from(self::SENT)->groupBy('event_id') + ->where($qb->expr()->gt('event_id',$qb->createNamedParameter(0,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('status',$qb->createNamedParameter('retry'))) + ->andWhere($qb->expr()->lte('claim_until',$qb->createNamedParameter(time(),IQueryBuilder::PARAM_INT))) + ->setMaxResults(max(1,min($limit,500)))->executeQuery(); + $ids=array_map('intval',$rs->fetchFirstColumn());$rs->closeCursor(); + foreach ($ids as $eventId) { $this->jobs->add(EventChannelDeliveryJob::class,['eventId'=>$eventId]); } + return count($ids); + } + + /** Non-personalized publication overview for admin diagnostics. */ + public function stats(int $id):array { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('channel','status',$qb->func()->count('id').' AS total') + ->from(self::SENT) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->groupBy('channel','status')->executeQuery(); + $rows=$rs->fetchAllAssociative();$rs->closeCursor(); + return $rows; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php new file mode 100644 index 0000000..7849860 --- /dev/null +++ b/lib/Service/BoardService.php @@ -0,0 +1,339 @@ +campaigns->isEligible($c,$uid,time())) { return false; } + if (!in_array($c['trigger_key'],['nextcloud.board.published','nextcloud.time.window'],true) + && !isset($eventInbox[(int)$c['id'].':'.(int)$c['published_at']])) { return false; } + return !($c['category']==='sponsored' && + $this->config->getUserValue($uid,'communications','sponsored_optout','0')==='1'); + } + + /** Recorded event-triggered Inbox receipt, indexed by campaign and epoch. */ + private function eventInboxReceipts(string $uid):array { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('campaign_id','version')->from('cc_board_sent')->groupBy('campaign_id','version') + ->where($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('channel',$qb->createNamedParameter('inbox'))) + ->andWhere($qb->expr()->in('status',$qb->createNamedParameter(['claimed','sent'],IQueryBuilder::PARAM_STR_ARRAY))) + ->executeQuery(); + $keys=[]; + foreach ($rs->fetchAllAssociative() as $row) { + $keys[(int)$row['campaign_id'].':'.(int)$row['version']]=true; + } + $rs->closeCursor(); + return $keys; + } + + /** Keyset pagination of true impressions, preserving the oldest receipts. */ + private function deliveredAt(string $uid):array { + $map=[]; $before=null; + do { + $qb=$this->db->getQueryBuilder(); + $qb->select('id','campaign_id','impression_at')->from('cc_deliveries') + ->where($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->isNotNull('impression_at')) + ->orderBy('id','DESC')->setMaxResults(500); + if ($before!==null) { + $qb->andWhere($qb->expr()->lt('id',$qb->createNamedParameter($before,IQueryBuilder::PARAM_INT))); + } + $rs=$qb->executeQuery();$rows=$rs->fetchAllAssociative();$rs->closeCursor(); + foreach($rows as $row) { + $before=(int)$row['id'];$id=(int)$row['campaign_id']; + $map[$id]=max($map[$id]??0,(int)$row['impression_at']); + } + }while(count($rows)===500); + return $map; + } + + /** Keyset pagination of user reading state, no first-2000 truncation. */ + private function readAt(string $uid):array { + $map=[];$after=null; + do{ + $qb=$this->db->getQueryBuilder(); + $qb->select('campaign_id','read_at')->from('cc_inbox_reads') + ->where($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->orderBy('campaign_id','ASC')->setMaxResults(500); + if($after!==null){ + $qb->andWhere($qb->expr()->gt('campaign_id',$qb->createNamedParameter($after,IQueryBuilder::PARAM_INT))); + } + $rs=$qb->executeQuery();$rows=$rs->fetchAllAssociative();$rs->closeCursor(); + foreach($rows as $row){$after=(int)$row['campaign_id'];$map[$after]=(int)$row['read_at'];} + }while(count($rows)===500); + return $map; + } + + private function countComments(int $id): int { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select($qb->func()->count('id'))->from(self::TABLE) + ->where($qb->expr()->eq('campaign_id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('deleted_at'))->executeQuery(); + $n = (int)$rs->fetchOne(); $rs->closeCursor(); return $n; + } + + private function payload(array $c,string $uid,?int $deliveredAt=null,?int $readAt=null):array { + $c = CampaignRepository::localize($c, $this->config->getUserValue($uid, 'core', 'lang', '')); + return [ + 'id'=>$c['id'], 'headline'=>$c['headline'], 'message'=>$c['message'], + 'ctaLabel'=>$c['cta_label'], 'ctaUrl'=>$c['cta_url'], + 'publishedAt'=>$c['published_at'] ?: ($deliveredAt ?: $c['created_at']), + 'deliveredAt'=>$deliveredAt,'readAt'=>$readAt, + 'source'=>$c['board_visible'] ? 'publicacao' : ($deliveredAt!==null?'contextual':'canal'), + 'startsAt'=>$c['starts_at'], + 'endsAt'=>$c['ends_at'], 'inInbox'=>$c['board_visible'], + 'commentsEnabled'=>$c['board_visible'] && $c['comments_enabled'], + 'commentCount'=>$this->countComments((int)$c['id']), + 'textAlign'=>$c['text_align'], 'textColor'=>$c['text_color'], + 'author'=>$this->users->getDisplayName($c['created_by']) ?: $c['created_by'], + 'category'=>$c['category'], + ]; + } + + /** Filter access before searching, counting and paginating, including historic deliveries. */ + public function list(string $uid,int $page=1,string $search='',string $filter='all'):array { + $page=max(1,min(100000,$page)); + $search=mb_strtolower(mb_substr(trim($search),0,80)); + $receipts=$this->deliveredAt($uid); + $eventInbox=$this->eventInboxReceipts($uid); + $reads=$this->readAt($uid); + $found=[]; + $unreadTotal=0; + foreach ($this->campaigns->scan() as $c) { + $id=(int)$c['id']; + if (!$this->eligible($c,$uid,$eventInbox)) { continue; } + $unread=!(isset($reads[$id]) && $reads[$id]>0); + if ($unread) { $unreadTotal++; } + if ($filter==='unread' && !$unread) { continue; } + $localized=CampaignRepository::localize($c,$this->config->getUserValue($uid,'core','lang','')); + if ($search!=='' && !str_contains(mb_strtolower( + $c['headline'].' '.$c['message'].' '.$localized['headline'].' '.$localized['message']),$search)) { continue; } + $found[]=$c; + } + usort($found,fn(array $a,array $b):int=> + (($b['published_at'] ?: ($receipts[(int)$b['id']]??$b['created_at'])) + <=> ($a['published_at'] ?: ($receipts[(int)$a['id']]??$a['created_at']))) + ?: ($b['id'] <=> $a['id'])); + $total=count($found);$size=10; + $slice=array_slice($found,($page-1)*$size,$size); + return [ + 'announcements'=>array_map(fn(array $c)=>$this->payload($c,$uid, + $receipts[(int)$c['id']]??null,$reads[(int)$c['id']]??null),$slice), + 'unreadTotal'=>$unreadTotal, + 'page'=>$page,'pageSize'=>$size,'total'=>$total,'hasMore'=>$page*$size<$total, + ]; + } + + /** Direct-link access for an explicitly sent channel-only notification. + * Access never promotes the publication into the Inbox list. + */ + private function outboundFor(string $uid,array $c):bool { + if ($c['status']!=='active' || $c['published_at']<=0 + || !$this->campaigns->isEligible($c,$uid,time()) + || ($c['category']==='sponsored' + && $this->config->getUserValue($uid,'communications','sponsored_optout','0')==='1')) { + return false; + } + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('id')->from('cc_board_sent') + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter((int)$c['id'],IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter((int)$c['published_at'],IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + // The notification renderer may run synchronously while the row is claimed. + ->andWhere($qb->expr()->in('status',$qb->createNamedParameter(['claimed','sent'],IQueryBuilder::PARAM_STR_ARRAY))) + ->setMaxResults(1)->executeQuery(); + $authorized=$rs->fetchOne()!==false;$rs->closeCursor(); + return $authorized; + } + + public function view(string $uid,int $id):?array { + $c=$this->campaigns->find($id); + if (!$c) { return null; } + $receipts=$this->deliveredAt($uid); + if (!$this->eligible($c,$uid,$this->eventInboxReceipts($uid)) + && !$this->outboundFor($uid,$c)) { return null; } + $reads=$this->readAt($uid); + return $this->payload($c,$uid,$receipts[$id]??null,$reads[$id]??null); + } + + /** Explicit user action; GET requests never change inbox state. */ + public function markRead(string $uid,int $id):bool { + if ($this->view($uid,$id)===null) { return false; } + $campaign=$this->campaigns->find($id); + if ($campaign!==null && !$campaign['board_visible']) { + // Direct-only links are readable but do not create Inbox read receipts. + return true; + } + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('read_at')->from('cc_inbox_reads') + ->where($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $existing=$rs->fetchOne(); $rs->closeCursor(); + if ($existing!==false) { return true; } + $qb=$this->db->getQueryBuilder(); + $qb->insert('cc_inbox_reads')->values([ + 'uid'=>$qb->createNamedParameter($uid), + 'campaign_id'=>$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT), + 'read_at'=>$qb->createNamedParameter(time(),IQueryBuilder::PARAM_INT), + ])->executeStatement(); + return true; + } + + public function comments(string $uid, int $id, int $before = 0): ?array { + if ($this->view($uid,$id) === null) { return null; } + $qb = $this->db->getQueryBuilder(); + $qb->select('id','uid','message','created_at')->from(self::TABLE) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('deleted_at')); + if ($before > 0) { + $qb->andWhere($qb->expr()->lt('id',$qb->createNamedParameter($before,IQueryBuilder::PARAM_INT))); + } + $rs = $qb->orderBy('id','DESC')->setMaxResults(21)->executeQuery(); + $rows = $rs->fetchAllAssociative(); $rs->closeCursor(); + $more = count($rows) > 20; + $rows = array_slice($rows,0,20); + $items = array_map(function(array $row) use($uid,$id):array { + return ['id'=>(int)$row['id'],'campaignId'=>$id,'uid'=>$row['uid'], + 'author'=>$this->users->getDisplayName($row['uid']) ?: $row['uid'], + 'message'=>$row['message'],'createdAt'=>(int)$row['created_at'], + 'canDelete'=>$uid===$row['uid'] || $this->groups->isAdmin($uid)]; + },$rows); + return ['comments'=>$items,'hasMore'=>$more,'nextBefore'=>$more ? (int)end($rows)['id'] : null]; + } + + /** + * Mention suggestions are restricted to users who can currently read this + * particular announcement. No email addresses or global user list returned. + */ + public function mentionCandidates(string $uid,int $id,string $search):?array { + $item=$this->view($uid,$id); + if($item===null || !$item['commentsEnabled'])return null; + $search=trim($search); + if(mb_strlen($search)<2 || mb_strlen($search)>40 + || !preg_match('/^[A-Za-z0-9_.-]{2,40}$/D',$search))return []; + $campaign=$this->campaigns->find($id); + if($campaign===null || !$campaign['board_visible'])return []; + $results=[]; + foreach([$this->users->search($search,30), + $this->users->searchDisplayName($search,30)] as $matches) { + foreach($matches as $user) { + $candidate=$user->getUID(); + if($candidate===$uid || isset($results[$candidate]) + || !$this->campaigns->isEligible($campaign,$candidate,time()) + || ($campaign['category']==='sponsored' + && $this->config->getUserValue($candidate,'communications','sponsored_optout','0')==='1'))continue; + $results[$candidate]=[ + 'id'=>$candidate,'displayName'=>$user->getDisplayName(), + ]; + if(count($results)>=10)break 2; + } + } + return array_values($results); + } + + public function createComment(string $uid,int $id,string $message): array { + $c = $this->view($uid,$id); + if ($c===null) { throw new InvalidArgumentException('Comunicado indisponível.'); } + if (!$c['commentsEnabled']) { throw new InvalidArgumentException('Comentários desabilitados.'); } + $message = trim($message); + if ($message==='' || mb_strlen($message)>4000) { + throw new InvalidArgumentException('Informe um comentário de até 4.000 caracteres.'); + } + $now = time(); + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('id')->from(self::TABLE) + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->gte('created_at',$qb->createNamedParameter($now-5,IQueryBuilder::PARAM_INT))) + ->setMaxResults(1)->executeQuery(); + $recent = $rs->fetchOne(); $rs->closeCursor(); + if ($recent!==false) { throw new InvalidArgumentException('Aguarde alguns segundos antes de comentar novamente.'); } + $insert = $this->db->getQueryBuilder(); + $insert->insert(self::TABLE)->values([ + 'campaign_id'=>$insert->createNamedParameter($id,IQueryBuilder::PARAM_INT), + 'uid'=>$insert->createNamedParameter($uid), + 'message'=>$insert->createNamedParameter($message), + 'created_at'=>$insert->createNamedParameter($now,IQueryBuilder::PARAM_INT), + 'updated_at'=>$insert->createNamedParameter($now,IQueryBuilder::PARAM_INT), + ])->executeStatement(); + $commentId=(int)$this->db->lastInsertId(self::TABLE); + $this->notifyMentions($uid,$id,$commentId,$message); + return ['id'=>$commentId,'campaignId'=>$id,'uid'=>$uid, + 'author'=>$this->users->getDisplayName($uid) ?: $uid, + 'message'=>$message,'createdAt'=>$now,'canDelete'=>true]; + } + + private function notifyMentions(string $actor,int $campaignId,int $commentId,string $message):void { + preg_match_all('/(?users->get($uid)===null + || $this->view($uid,$campaignId)===null) { continue; } + try { + $n=$this->notifications->createNotification(); + $n->setApp('communications')->setUser($uid) + ->setDateTime(new \DateTime()) + ->setObject('cc_board_comment',(string)$commentId) + ->setSubject('mention',[$actor,(string)$campaignId]); + $this->notifications->notify($n); + } catch (\Throwable $ignored) { + // A notification failure must never cancel a stored comment. + } + } + } + + public function deleteComment(string $uid,int $campaignId,int $commentId):bool { + if ($this->view($uid,$campaignId)===null) { return false; } + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('uid')->from(self::TABLE) + ->where($qb->expr()->eq('id',$qb->createNamedParameter($commentId,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('campaign_id',$qb->createNamedParameter($campaignId,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('deleted_at'))->executeQuery(); + $author=$rs->fetchOne(); $rs->closeCursor(); + if ($author===false || ($author!==$uid && !$this->groups->isAdmin($uid))) { return false; } + $qb=$this->db->getQueryBuilder(); + return $qb->update(self::TABLE) + ->set('deleted_at',$qb->createNamedParameter(time(),IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id',$qb->createNamedParameter($commentId,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('campaign_id',$qb->createNamedParameter($campaignId,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('deleted_at'))->executeStatement()===1; + } + + public function notificationAllowed(string $uid,int $commentId):?array { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('campaign_id','uid')->from(self::TABLE) + ->where($qb->expr()->eq('id',$qb->createNamedParameter($commentId,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('deleted_at'))->executeQuery(); + $row=$rs->fetchAssociative(); $rs->closeCursor(); + if (!$row || $this->view($uid,(int)$row['campaign_id'])===null) { return null; } + return ['campaignId'=>(int)$row['campaign_id'],'actor'=>$row['uid']]; + } +} diff --git a/lib/Service/CampaignRepository.php b/lib/Service/CampaignRepository.php new file mode 100644 index 0000000..84b04c0 --- /dev/null +++ b/lib/Service/CampaignRepository.php @@ -0,0 +1,744 @@ + 'Login interativo', + 'nextcloud.files.created' => 'Arquivo criado (Files)', + 'nextcloud.files.updated' => 'Arquivo atualizado (Files)', + 'nextcloud.files.deleted' => 'Arquivo removido (Files)', + 'nextcloud.user.first_login' => 'Primeiro login', + 'nextcloud.share.created' => 'Compartilhamento criado', + 'nextcloud.share.accepted' => 'Compartilhamento aceito', + 'nextcloud.comment.created' => 'Comentário publicado', + 'nextcloud.page.opened' => 'Página de aplicativo aberta', + 'nextcloud.time.window' => 'Campanha agendada por data', + 'nextcloud.flow.matched' => 'Regra do Nextcloud Flow atendida', + 'external.webhook' => 'Webhook externo autenticado', + 'nextcloud.board.published' => 'Comunicado persistente no mural', + ]; + public const STATUSES = ['draft', 'active', 'paused', 'archived']; + public const PRESENTATIONS = ['banner', 'modal', 'board', 'none']; + public const PLACEMENTS = ['any', 'files', 'dashboard', 'settings', 'admin_settings']; + public const FREQUENCIES = ['event', 'once', 'daily', 'weekly', 'custom']; + public const CATEGORIES = ['institutional', 'sponsored']; + // Preserve legacy themes while supporting the four native NcNoteCard types. + public const THEMES = ['default', 'primary', 'info', 'success', 'warning', 'error', 'neutral', 'custom']; + public const BANNER_ICONS = [ + 'auto','none','info','success','warning','error','shield', + 'megaphone','bullhorn','bell','bell-ring','alert','alert-circle','alert-octagon', + 'information','information-outline','check-circle','check-decagram', + 'close-circle','close-octagon','shield-alert','shield-check','lock', + 'wrench','progress-wrench','broom','server','server-off','database', + 'cloud','cloud-alert','wifi-off','sync','update','download','upload', + 'calendar','calendar-clock','clock-alert','email','flag','star', + 'gift','party-popper','rocket-launch','new-box','lightning-bolt','help-circle', + ]; + private const MAX_TARGETS = 40; + + public function __construct( + private IDBConnection $db, + private IGroupManager $groupManager, + private IUserManager $userManager, + private TriggerCatalog $triggerCatalog, + private WebRouteCatalog $webRoutes, + private NativeEventSubscriptions $nativeSubscriptions, + ) {} + + /** + * Stable keyset pagination. Historical records are never silently hidden + * by an arbitrary 500-row cutoff. + * @return \Generator + */ + public function scan():\Generator { + $before=null; + do { + $qb=$this->db->getQueryBuilder(); + $qb->select('*')->from(self::TABLE)->orderBy('id','DESC')->setMaxResults(250); + if ($before!==null) { + $qb->where($qb->expr()->lt('id',$qb->createNamedParameter($before,IQueryBuilder::PARAM_INT))); + } + $rs=$qb->executeQuery(); + $rows=$rs->fetchAllAssociative();$rs->closeCursor(); + foreach ($rows as $row) { + $before=(int)$row['id']; + yield $this->castRow($row); + } + } while (count($rows)===250); + } + + /** Legacy admin and CLI list. Prefer pagedList() for large UI datasets. */ + public function all():array { + $rows=iterator_to_array($this->scan(),false); + usort($rows,static fn(array $a,array $b):int => + ($b['updated_at'] <=> $a['updated_at']) ?: ($b['id']<=>$a['id'])); + return $rows; + } + + /** + * Strictly public, preapproved banner projection. Never include audience IDs, + * group membership, creator, metrics, unpublished records or internal data. + */ + public function publicForGuests(int $now):array { + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('*')->from(self::TABLE) + ->where($qb->expr()->eq('guest_visible',$qb->createNamedParameter(1,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('status',$qb->createNamedParameter('active'))) + ->andWhere($qb->expr()->eq('presentation',$qb->createNamedParameter('banner'))) + ->andWhere($qb->expr()->eq('audience_type',$qb->createNamedParameter('all'))) + ->andWhere($qb->expr()->eq('trigger_key',$qb->createNamedParameter('nextcloud.time.window'))) + ->andWhere($qb->expr()->eq('category',$qb->createNamedParameter('institutional'))) + ->orderBy('priority','DESC')->addOrderBy('manual_order','ASC') + ->setMaxResults(30)->executeQuery(); + $rows=$rs->fetchAllAssociative();$rs->closeCursor(); + $out=[]; + foreach ($rows as $row) { + $c=$this->castRow($row); + if($c['starts_at']!==null && $c['starts_at']>$now)continue; + if($c['ends_at']!==null && $c['ends_at']<=$now)continue; + if($c['placement']!=='any' + || !in_array($c['app_target_mode'],['legacy','all'],true) + || $c['board_visible'])continue; + $out[]=[ + 'id'=>$c['id'],'version'=>$c['updated_at'], + 'headline'=>$c['headline'],'message'=>$c['message'], + 'ctaLabel'=>$c['cta_label'], + 'ctaUrl'=>CtaUrl::isAllowed($c['cta_url'])?$c['cta_url']:'', + 'type'=>$c['theme'],'color'=>$c['banner_color'], + 'dismissible'=>$c['dismissible'], + 'startsAt'=>$c['starts_at'],'endsAt'=>$c['ends_at'], + ]; + if(count($out)>=5)break; + } + return $out; + } + + public function find(int $id): ?array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*')->from(self::TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $row = $result->fetchAssociative(); + $result->closeCursor(); + return $row === false ? null : $this->castRow($row); + } + + public function activeForTrigger(string $trigger): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*')->from(self::TABLE) + ->where($qb->expr()->eq('trigger_key', $qb->createNamedParameter($trigger))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter('active'))) + ->orderBy('priority', 'DESC')->addOrderBy('id', 'ASC') + ->setMaxResults(100)->executeQuery(); + $rows = $result->fetchAllAssociative(); + $result->closeCursor(); + return array_map($this->castRow(...), $rows); + } + + /** + * Resolve editorial translations without exposing the full locale dictionary + * to announcement readers. A missing locale falls back to the authored copy. + */ + public static function localize(array $campaign, string $locale): array { + $locale = str_replace('-', '_', $locale); + $map = $campaign['translated_content'] ?? []; + if (!is_array($map) || $map === []) { return $campaign; } + $short = substr($locale,0,2); + $choice = $map[$locale] ?? $map[$short] ?? null; + if (!is_array($choice)) { + foreach ($map as $name => $translation) { + if (is_string($name) && str_starts_with($name, $short.'_') && is_array($translation)) { + $choice = $translation; + break; + } + } + } + if (!is_array($choice)) { return $campaign; } + foreach (['headline','message','cta_label'] as $field) { + if (is_string($choice[$field] ?? null) && trim($choice[$field]) !== '') { + $campaign[$field] = $choice[$field]; + } + } + return $campaign; + } + + public function isEligible(array $campaign, string $uid, int $now): bool { + if ($campaign['status'] !== 'active') { return false; } + if ($campaign['starts_at'] !== null && $campaign['starts_at'] > $now) { return false; } + if ($campaign['ends_at'] !== null && $campaign['ends_at'] <= $now) { return false; } + return $this->isAudienceMember($campaign, $uid); + } + + /** Recheck current audience even for a previously delivered, expired item. */ + public function isAudienceMember(array $campaign,string $uid):bool { + if ($campaign['excluded_groups'] !== []) { + $excluded = ($campaign['excluded_match'] ?? 'any') === 'all' + ? $this->inAllGroups($uid, $campaign['excluded_groups']) + : $this->inAnyGroup($uid, $campaign['excluded_groups']); + if ($excluded) { return false; } + } + return match ($campaign['audience_type']) { + 'all' => true, + 'admins' => $this->groupManager->isAdmin($uid), + 'group' => $campaign['group_match'] === 'all' + ? $this->inAllGroups($uid, $campaign['audience_groups']) + : $this->inAnyGroup($uid, $campaign['audience_groups']), + 'users' => in_array($uid, $campaign['audience_users'], true), + default => false, + }; + } + + private function inAllGroups(string $uid, array $groups): bool { + if ($groups === []) { return false; } + foreach ($groups as $groupId) { + if (!$this->groupManager->isInGroup($uid, $groupId)) { return false; } + } + return true; + } + + private function inAnyGroup(string $uid, array $groups): bool { + foreach ($groups as $groupId) { + if ($this->groupManager->isInGroup($uid, $groupId)) { return true; } + } + return false; + } + + public function groupOptions(): array { + $options = []; + foreach ($this->groupManager->search('', 150) as $group) { + $options[] = ['id' => $group->getGID(), 'name' => $group->getDisplayName()]; + } + usort($options, fn (array $a, array $b) => strcasecmp($a['name'], $b['name'])); + return $options; + } + + public function userOptions(string $search): array { + $search = trim(mb_substr($search, 0, 80)); + if ($search === '') { return []; } + $found = []; + // Nextcloud searches UIDs and display names separately. Deduplicate by UID. + foreach ([$this->userManager->search($search, 30), $this->userManager->searchDisplayName($search, 30)] as $users) { + foreach ($users as $user) { + $uid = $user->getUID(); + $found[$uid] = ['id' => $uid, 'name' => $user->getDisplayName()]; + if (count($found) >= 40) { break 2; } + } + } + return array_values($found); + } + + /** + * Target IDs are validated on the server; the browser never decides authorization. + * 'group' is accepted as a legacy single-group input for existing API consumers. + */ + private function targets(array $payload, string $field, string $legacyField, bool $required): array { + $values = $payload[$field] ?? null; + if ($values === null && $legacyField !== '' && isset($payload[$legacyField])) { + $legacy = $payload[$legacyField]; + $values = is_string($legacy) && trim($legacy) !== '' ? [trim($legacy)] : []; + } + if ($values === null) { $values = []; } + if (!is_array($values) || !array_is_list($values) || count($values) > self::MAX_TARGETS) { + throw new InvalidArgumentException('Seleção inválida para ' . $field . '.'); + } + $ids = []; + foreach ($values as $value) { + if (!is_string($value) || trim($value) !== $value || $value === '' || mb_strlen($value) > 64) { + throw new InvalidArgumentException('Identificador inválido em ' . $field . '.'); + } + $ids[$value] = $value; + } + if ($required && $ids === []) { throw new InvalidArgumentException('Selecione ao menos um destinatário.'); } + return array_values($ids); + } + + public function validate(array $payload): array { + $text = static function (string $key, int $max, bool $required = false) use ($payload): string { + $value = $payload[$key] ?? ''; + if (!is_string($value)) { throw new InvalidArgumentException('Campo inválido: ' . $key); } + $value = trim($value); + if (($required && $value === '') || mb_strlen($value) > $max) { + throw new InvalidArgumentException('Revise o campo ' . $key . ' (máximo ' . $max . ' caracteres).'); + } + return $value; + }; + $name = $text('name', 160, true); + $description = $text('description', 700); + $headline = $text('headline', 180, true); + $message = $text('message', 4000, true); + $ctaLabel = $text('cta_label', 80); + $ctaUrl = $text('cta_url', 1900); + if (($ctaLabel === '') !== ($ctaUrl === '')) { + throw new InvalidArgumentException('Informe tanto o texto quanto o endereço do botão.'); + } + if ($ctaUrl !== '' && !CtaUrl::isAllowed($ctaUrl)) { + throw new InvalidArgumentException('Link inválido. Use HTTP(S), mailto:, tel: ou caminho interno / sem caracteres inseguros.'); + } + $trigger = $text('trigger_key', 64, true); + if (!$this->triggerCatalog->supports($trigger)) { throw new InvalidArgumentException('Este gatilho não está disponível. Verifique se a integração está habilitada.'); } + $presentation = $text('presentation', 12, true); + if (!in_array($presentation, self::PRESENTATIONS, true)) { + throw new InvalidArgumentException('Formato de apresentação inválido.'); + } + $audience = $text('audience_type', 20, true); + if (!in_array($audience, ['all', 'admins', 'group', 'users'], true)) { + throw new InvalidArgumentException('Público-alvo inválido.'); + } + $groups = $audience === 'group' + ? $this->targets($payload, 'audience_groups', 'audience_group', true) : []; + $users = $audience === 'users' + ? $this->targets($payload, 'audience_users', '', true) : []; + foreach ($groups as $group) { + if ($this->groupManager->get($group) === null) { + throw new InvalidArgumentException('O grupo selecionado não existe: ' . $group); + } + } + foreach ($users as $uid) { + if ($this->userManager->get($uid) === null) { + throw new InvalidArgumentException('O usuário selecionado não existe.'); + } + } + $excluded = $this->targets($payload, 'excluded_groups', '', false); + foreach ($excluded as $groupId) { + if ($this->groupManager->get($groupId) === null) { + throw new InvalidArgumentException('Um grupo excluído não existe.'); + } + } + $groupMatch = $text('group_match', 8) ?: 'any'; + if (!in_array($groupMatch, ['any', 'all'], true)) { + throw new InvalidArgumentException('Critério de grupos inválido.'); + } + $excludedMatch = $text('excluded_match', 8) ?: 'any'; + if (!in_array($excludedMatch, ['any', 'all'], true)) { + throw new InvalidArgumentException('Critério de exclusão de grupos inválido.'); + } + $placement = $text('placement', 32) ?: 'any'; + if (!$this->triggerCatalog->supportsPlacement($placement)) { + throw new InvalidArgumentException('Local de apresentação inválido.'); + } + $appTargetMode = $text('app_target_mode', 12) ?: 'legacy'; + if (!in_array($appTargetMode, ['legacy','all','only','exclude'], true)) { + throw new InvalidArgumentException('Modo de seleção de aplicativos inválido.'); + } + $appTargetApps = []; + if ($trigger === 'nextcloud.page.opened' || in_array($presentation,['board','none'],true)) { + $appTargetMode = 'legacy'; + } elseif (in_array($appTargetMode, ['only','exclude'], true)) { + $appTargetApps = $this->targets($payload,'app_target_apps','',true); + foreach ($appTargetApps as $appId) { + if ($appId==='any' || !$this->triggerCatalog->supportsPlacement($appId)) { + throw new InvalidArgumentException('Aplicativo selecionado não está disponível: '.$appId); + } + } + } + $pageRoute = $text('page_route', 255); + $pageFilters = $payload['page_filters'] ?? []; + if (!is_array($pageFilters)) { throw new InvalidArgumentException('Filtros de rota inválidos.'); } + if ($trigger === 'nextcloud.page.opened') { + // Page triggers need a concrete navigable app even when its route is wildcard. + // Otherwise the saved campaign can never match a browser visit. + if (in_array($placement,['any','settings','admin_settings'],true)) { + throw new InvalidArgumentException('Selecione um aplicativo com páginas para o gatilho de navegação.'); + } + if ($pageRoute !== '' && !$this->webRoutes->isSelectable($placement,$pageRoute)) { + throw new InvalidArgumentException('Selecione uma rota web válida do aplicativo, não uma rota de API.'); + } + $pageFilters = $this->webRoutes->normalizeFilters($pageRoute, $pageFilters); + } else { + $pageRoute = ''; + $pageFilters = []; + } + $frequency = $text('frequency', 12) ?: 'event'; + if (!in_array($frequency, self::FREQUENCIES, true)) { + throw new InvalidArgumentException('Frequência inválida.'); + } + $category = $text('category', 16) ?: 'institutional'; + if (!in_array($category, self::CATEGORIES, true)) { + throw new InvalidArgumentException('Categoria de campanha inválida.'); + } + $boardVisible = ($payload['board_visible'] ?? false) === true || ($payload['board_visible'] ?? 0) === 1 || $presentation === 'board'; + $guestVisible = $payload['guest_visible'] ?? false; + if (!in_array($guestVisible,[true,false,0,1],true)) { + throw new InvalidArgumentException('Opção de visitantes sem sessão inválida.'); + } + $guestVisible = $guestVisible===true || $guestVisible===1; + if ($guestVisible && ($audience!=='all' + || $presentation!=='banner' || $trigger!=='nextcloud.time.window' + || $category!=='institutional' || $placement!=='any' + || !in_array($appTargetMode,['legacy','all'],true) + || $boardVisible)) { + throw new InvalidArgumentException('Visitantes sem sessão exigem banner institucional, público geral, por data e sem filtro de aplicativo.'); + } + $notificationFlags = []; + foreach (['notify_users','notify_activity','notify_email'] as $key) { + $value = $payload[$key] ?? false; + if (!in_array($value,[true,false,0,1],true)) { + throw new InvalidArgumentException('Configuração inválida do canal: '.$key); + } + $notificationFlags[$key] = ($value === true || $value === 1) ? 1 : 0; + } + $gatewayChannels=$payload['gateway_channels'] ?? []; + if (!is_array($gatewayChannels) || !array_is_list($gatewayChannels) + || count($gatewayChannels)>count(GatewayTransport::LABELS) + || count($gatewayChannels)!==count(array_unique($gatewayChannels, SORT_REGULAR))) { + throw new InvalidArgumentException('Canais Two-Factor Gateway inválidos.'); + } + foreach ($gatewayChannels as $gateway) { + if (!is_string($gateway) || !isset(GatewayTransport::LABELS[$gateway])) { + throw new InvalidArgumentException('Provedor Two-Factor Gateway desconhecido.'); + } + } + $hasOutbound=array_sum($notificationFlags)>0 || $gatewayChannels!==[]; + if ($guestVisible && $hasOutbound) { + throw new InvalidArgumentException('Banners públicos anônimos não enviam mensagens individuais.'); + } + // Any trigger may deliver without a visual surface. All channels follow + // the same event occurrence; a recipient-scoped outbox prevents global + // sends when an individual action is observed. + if ($presentation==='none' && !$boardVisible && !$hasOutbound) { + throw new InvalidArgumentException('Escolha ao menos um destino de entrega.'); + } + $commentsEnabled = ($payload['comments_enabled'] ?? true) !== false && ($payload['comments_enabled'] ?? 1) !== 0; + $dismissible = ($payload['dismissible'] ?? true) !== false && ($payload['dismissible'] ?? 1) !== 0; + $alignment = $text('text_align', 8) ?: 'left'; + if (!in_array($alignment, ['left','center','right'], true)) { + throw new InvalidArgumentException('Alinhamento inválido.'); + } + $textColor = $text('text_color', 9); + if ($textColor !== '' && !preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/D', $textColor)) { + throw new InvalidArgumentException('Cor do texto inválida.'); + } + if (strlen($textColor) === 4) { + $textColor = '#' . $textColor[1] . $textColor[1] + . $textColor[2] . $textColor[2] . $textColor[3] . $textColor[3]; + } + $manualOrder = $payload['manual_order'] ?? 0; + if (!is_int($manualOrder) && (!is_string($manualOrder) || !preg_match('/^-?[0-9]{1,6}$/D', $manualOrder))) { + throw new InvalidArgumentException('Ordem de apresentação inválida.'); + } + $manualOrder = (int)$manualOrder; + if ($manualOrder < -1000000 || $manualOrder > 1000000) { throw new InvalidArgumentException('Ordem fora do limite.'); } + $translated = $payload['translated_content'] ?? []; + if (!is_array($translated)) { throw new InvalidArgumentException('Traduções inválidas.'); } + if (count($translated) > 12) { throw new InvalidArgumentException('Número excessivo de idiomas.'); } + $safeTranslations = []; + foreach ($translated as $locale => $item) { + if (!is_string($locale) || !preg_match('/^[a-z]{2}(?:_[A-Z]{2})?$/D', $locale) + || !is_array($item) || array_is_list($item) || count($item) > 3) { + throw new InvalidArgumentException('Tradução com formato inválido.'); + } + if (array_diff(array_keys($item), ['headline','message','cta_label']) !== []) { + throw new InvalidArgumentException('Campos de tradução não permitidos.'); + } + $values = []; + foreach (['headline'=>180, 'message'=>5000, 'cta_label'=>80] as $field => $max) { + if (!isset($item[$field])) { continue; } + if (!is_string($item[$field]) || mb_strlen($item[$field]) > $max) { + throw new InvalidArgumentException('Texto traduzido inválido.'); + } + $values[$field] = trim($item[$field]); + } + $safeTranslations[$locale] = $values; + } + // Every delivery surface is eligible for an immediate publication: + // visual destinations appear on each eligible user's next visit; + // asynchronous channels are queued separately on activation. + // The legacy "board" presentation remains accepted for saved content. + $theme = $text('theme', 12) ?: 'default'; + if (!in_array($theme, self::THEMES, true)) { + throw new InvalidArgumentException('Tema inválido.'); + } + $bannerColor = $text('banner_color', 9) ?: '#6748AC'; + if (!preg_match('/^#(?:[a-fA-F0-9]{3}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/D', $bannerColor)) { + throw new InvalidArgumentException('A cor deve usar #RGB, #RRGGBB ou #RRGGBBAA.'); + } + if (strlen($bannerColor) === 4) { + $bannerColor = '#' . $bannerColor[1] . $bannerColor[1] + . $bannerColor[2] . $bannerColor[2] . $bannerColor[3] . $bannerColor[3]; + } + $bannerColor = strtoupper($bannerColor); + $bannerIcon = $text('banner_icon', 12) ?: 'auto'; + if (!in_array($bannerIcon, self::BANNER_ICONS, true)) { + throw new InvalidArgumentException('Selecione um ícone permitido ou a opção sem ícone.'); + } + $integer = static function (string $name, int $max) use ($payload): int { + $value = $payload[$name] ?? 0; + if (filter_var($value, FILTER_VALIDATE_INT) === false || (int)$value < 0 || (int)$value > $max) { + throw new InvalidArgumentException('Número inválido: ' . $name); + } + return (int)$value; + }; + $minInterval = $integer('min_interval_minutes', 43200); + $maxTotal = $integer('max_total', 10000); + $maxPerDay = $integer('max_per_day', 100); + if ($category === 'sponsored' && $maxPerDay === 0) { + $maxPerDay = 2; // Conservative ceiling for this POC + } + $status = $text('status', 12, true); + if (!in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('Status da campanha inválido.'); + } + $priority = $payload['priority'] ?? 10; + if (filter_var($priority, FILTER_VALIDATE_INT) === false || (int)$priority < 0 || (int)$priority > 100) { + throw new InvalidArgumentException('A prioridade deve estar entre 0 e 100.'); + } + $parseTimestamp = static function (string $key) use ($payload): ?int { + $value = $payload[$key] ?? null; + if ($value === null || $value === '') { return null; } + if (filter_var($value, FILTER_VALIDATE_INT) === false || (int)$value < 0) { + throw new InvalidArgumentException('Data inválida: ' . $key); + } + return (int)$value; + }; + $starts = $parseTimestamp('starts_at'); + $ends = $parseTimestamp('ends_at'); + if ($trigger === 'nextcloud.time.window' && $starts === null) { + throw new InvalidArgumentException('Campanhas por data exigem início de vigência.'); + } + if ($starts !== null && $ends !== null && $ends <= $starts) { + throw new InvalidArgumentException('O término deve ser posterior ao início.'); + } + return [ + 'name' => $name, 'description' => $description, 'headline' => $headline, + 'message' => $message, 'cta_label' => $ctaLabel, 'cta_url' => $ctaUrl, + 'trigger_key' => $trigger, 'audience_type' => $audience, + 'audience_group' => $groups[0] ?? '', + 'audience_groups' => json_encode($groups, JSON_THROW_ON_ERROR), + 'audience_users' => json_encode($users, JSON_THROW_ON_ERROR), + 'excluded_groups' => json_encode($excluded, JSON_THROW_ON_ERROR), + 'group_match' => $groupMatch, 'excluded_match' => $excludedMatch, 'placement' => $placement, + 'app_target_mode' => $appTargetMode, + 'app_target_apps' => json_encode($appTargetApps, JSON_THROW_ON_ERROR), + 'page_route' => $pageRoute, + 'page_filters' => json_encode($pageFilters, JSON_THROW_ON_ERROR), + 'frequency' => $frequency, + 'min_interval_minutes' => $minInterval, 'max_total' => $maxTotal, + 'max_per_day' => $maxPerDay, 'category' => $category, 'theme' => $theme, + 'board_visible' => $boardVisible ? 1 : 0, + 'guest_visible' => $guestVisible ? 1 : 0, + ...$notificationFlags, + 'gateway_channels'=>json_encode($gatewayChannels,JSON_THROW_ON_ERROR), + 'comments_enabled' => $commentsEnabled ? 1 : 0, + 'dismissible' => $dismissible ? 1 : 0, + 'text_align' => $alignment, 'text_color' => strtoupper($textColor), + 'manual_order' => $manualOrder, + 'translated_content' => json_encode($safeTranslations, JSON_THROW_ON_ERROR), + 'banner_color' => $bannerColor, 'banner_icon' => $bannerIcon, + 'presentation' => $presentation, 'status' => $status, 'priority' => (int)$priority, + 'starts_at' => $starts, 'ends_at' => $ends, + ]; + } + + public function create(array $data, string $uid): array { + $validated = $this->validate($data); + $this->nativeSubscriptions->assertCapacity($validated['trigger_key'], $validated['status']); + $now = time(); + $validated['created_by'] = $uid; + $validated['created_at'] = $now; + $validated['published_at'] = $validated['status'] === 'active' ? $now : 0; + $validated['updated_at'] = $now; + $qb = $this->db->getQueryBuilder(); + $values = []; + foreach ($validated as $column => $value) { + $type = is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR; + $values[$column] = $qb->createNamedParameter($value, $type); + } + $qb->insert(self::TABLE)->values($values)->executeStatement(); + // Capture the ID before refreshing subscriptions, which may write app config. + $id = $this->db->lastInsertId(self::TABLE); + if (str_starts_with($validated['trigger_key'], 'php_event.') && $validated['status'] === 'active') { + $this->nativeSubscriptions->refresh(); + } + $created = $this->find($id); + if ($created === null) { + throw new \RuntimeException('Created publication could not be loaded.'); + } + return $created; + } + + public function update(int $id, array $data): ?array { + $original = $this->find($id); + if ($original === null) { return null; } + $validated = $this->validate($data); + $this->nativeSubscriptions->assertCapacity($validated['trigger_key'], $validated['status'], $id); + $validated['updated_at'] = time(); + $validated['published_at'] = ($original['status'] !== 'active' && $validated['status'] === 'active') + ? time() : $original['published_at']; + $qb = $this->db->getQueryBuilder(); + $qb->update(self::TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + foreach ($validated as $column => $value) { + $qb->set($column, $qb->createNamedParameter($value, is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR)); + } + $qb->executeStatement(); + if (str_starts_with($original['trigger_key'], 'php_event.') + || str_starts_with($validated['trigger_key'], 'php_event.')) { + $this->nativeSubscriptions->refresh(); + } + return $this->find($id); + } + + /** + * Reorder non-archived campaigns; preserve audience, triggers and every + * other property. A transaction prevents partially persisted ordering. + * Keyboard buttons and drag-and-drop share this API. + */ + public function move(int $id,string $direction,?int $targetId=null):?array { + if (!in_array($direction,['up','down','before','after'],true)) { + throw new InvalidArgumentException('Movimento de ordenação inválido.'); + } + $rows=array_values(array_filter($this->all(), + static fn(array $c):bool=>$c['status']!=='archived')); + usort($rows,static fn(array $a,array $b):int=> + ($a['manual_order']<=>$b['manual_order']) + ?:($b['priority']<=>$a['priority']) + ?:($b['id']<=>$a['id'])); + $index=null; + foreach ($rows as $i=>$c) { if ((int)$c['id']===$id) {$index=$i;break;} } + if ($index===null) {return null;} + if ($direction==='up' || $direction==='down') { + $neighbor=$index+($direction==='up'?-1:1); + if ($neighbor<0 || $neighbor>=count($rows)) {return $rows;} + [$rows[$index],$rows[$neighbor]]=[$rows[$neighbor],$rows[$index]]; + } else { + if ($targetId===null || $targetId<1) { + throw new InvalidArgumentException('Selecione a campanha de destino.'); + } + if ($targetId===$id) {return $rows;} + $current=$rows[$index]; + array_splice($rows,$index,1); + $position=null; + foreach ($rows as $i=>$row) { + if ((int)$row['id']===$targetId) {$position=$i;break;} + } + if ($position===null) { + throw new InvalidArgumentException('Campanha de destino indisponível.'); + } + array_splice($rows,$position+($direction==='after'?1:0),0,[$current]); + } + $this->db->beginTransaction(); + try { + foreach ($rows as $i=>$c) { + $order=($i+1)*10; + if ((int)$c['manual_order']===$order) {continue;} + $qb=$this->db->getQueryBuilder(); + $qb->update(self::TABLE) + ->set('manual_order',$qb->createNamedParameter($order,IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id',$qb->createNamedParameter((int)$c['id'],IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + $this->db->commit(); + } catch (\Throwable $error) { + $this->db->rollBack(); + throw $error; + } + return $rows; + } + + /** + * Irreversible deletion is intentionally separate from the archive endpoint. + * Only archived records can be deleted, and caller must confirm exact name. + * Related delivery / publication evidence is removed transactionally; the + * global event stream remains shared between publications and is retained. + */ + public function purgeArchived(int $id,string $confirmName):bool { + $original=$this->find($id); + if ($original===null) {return false;} + if ($original['status']!=='archived') { + throw new InvalidArgumentException('Arquive a publicação antes de excluí-la definitivamente.'); + } + if ($confirmName==='' || !hash_equals($original['name'],$confirmName)) { + throw new InvalidArgumentException('Confirme exatamente o nome interno para excluir definitivamente.'); + } + $this->db->beginTransaction(); + try { + foreach (['cc_board_comments','cc_board_pub','cc_board_sent', + 'cc_inbox_reads','cc_deliveries','cc_leases'] as $table) { + $qb=$this->db->getQueryBuilder(); + $qb->delete($table) + ->where($qb->expr()->eq('campaign_id', + $qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + $qb=$this->db->getQueryBuilder(); + $deleted=$qb->delete(self::TABLE) + ->where($qb->expr()->eq('id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('status',$qb->createNamedParameter('archived'))) + ->executeStatement(); + if ($deleted!==1) { throw new \RuntimeException('Exclusão concorrente ou estado alterado.'); } + $this->db->commit(); + } catch (\Throwable $error) { + $this->db->rollBack(); + throw $error; + } + if (str_starts_with($original['trigger_key'],'php_event.')) { + $this->nativeSubscriptions->refresh(); + } + return true; + } + + public function setStatus(int $id, string $status): ?array { + if (!in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('Status da campanha inválido.'); + } + $original = $this->find($id); + if ($original === null) { return null; } + $this->nativeSubscriptions->assertCapacity($original['trigger_key'], $status, $id); + $qb = $this->db->getQueryBuilder(); + $qb->update(self::TABLE) + ->set('status', $qb->createNamedParameter($status)) + ->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)); + if ($status === 'active') { + $qb->set('published_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)); + } + $qb + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + if (str_starts_with($original['trigger_key'], 'php_event.')) { + $this->nativeSubscriptions->refresh(); + } + return $this->find($id); + } + + private function castRow(array $row): array { + // Legacy rows use the schema default marker for unrestricted pages. + if (!isset($row['page_route']) || $row['page_route'] === '*') { $row['page_route'] = ''; } + $rawFilters = json_decode($row['page_filters'] ?? '{}', true); + $row['page_filters'] = is_array($rawFilters) ? $rawFilters : []; + $row['text_color'] = $row['text_color'] ?? ''; + $row['excluded_match'] = $row['excluded_match'] ?? 'any'; + $translated = json_decode($row['translated_content'] ?? '{}', true); + $row['translated_content'] = is_array($translated) && !array_is_list($translated) ? $translated : []; + foreach (['board_visible','guest_visible','comments_enabled','dismissible','notify_users','notify_activity','notify_email'] as $flag) { + $row[$flag] = (int)($row[$flag] ?? 0) === 1; + } + foreach (['id', 'priority', 'created_at', 'updated_at', 'published_at', 'min_interval_minutes', 'max_total', 'max_per_day','manual_order'] as $field) { + $row[$field] = (int)$row[$field]; + } + foreach (['starts_at', 'ends_at'] as $field) { + $row[$field] = ($row[$field] === null || $row[$field] === '') ? null : (int)$row[$field]; + } + foreach (['audience_groups', 'audience_users', 'excluded_groups', 'app_target_apps', 'gateway_channels'] as $field) { + $value = $row[$field] ?? '[]'; + $decoded = json_decode($value, true); + $row[$field] = is_array($decoded) && array_is_list($decoded) + ? array_values(array_filter($decoded, 'is_string')) : []; + } + if ($row['audience_type'] === 'group' && $row['audience_groups'] === [] && $row['audience_group'] !== '') { + $row['audience_groups'] = [$row['audience_group']]; + } + return $row; + } +} diff --git a/lib/Service/CtaUrl.php b/lib/Service/CtaUrl.php new file mode 100644 index 0000000..d161e6d --- /dev/null +++ b/lib/Service/CtaUrl.php @@ -0,0 +1,22 @@ +1900 + || preg_match('/[\x00-\x20\x7f\x22\x27<>\x60\\\\]/',$url)) { return false; } + if (str_starts_with($url,'/')) { return !str_starts_with($url,'//'); } + if (preg_match('~^https?://~i',$url)) { + $parsed=parse_url($url); + return is_array($parsed) && !empty($parsed['host']) && !isset($parsed['user']) + && !isset($parsed['pass']) && filter_var($url,FILTER_VALIDATE_URL)!==false; + } + if (preg_match('/^mailto:[^@\s]+@[^@\s]+$/i',$url)) { return true; } + if (preg_match('/^tel:\+?[0-9().-]{3,30}$/i',$url)) { return true; } + return false; + } +} diff --git a/lib/Service/DeliveryOutcomeUnknownException.php b/lib/Service/DeliveryOutcomeUnknownException.php new file mode 100644 index 0000000..16c6126 --- /dev/null +++ b/lib/Service/DeliveryOutcomeUnknownException.php @@ -0,0 +1,11 @@ +triggerCatalog->supportsPlacement($app)) { + $app = 'any'; + } + // The browser reports its page, never a URL containing queries, hashes or + // private file paths. Validate against a navigable app before using it. + $pagePath = $this->webRoutes->safePagePath($app, $pagePath); + if ($newVisit && $pagePath !== '') { + // Page occurrences are scoped to a specific campaign, never broadcast to + // every app page. Store only the campaign ID, NOT the actual visitor path. + foreach ($this->campaigns->activeForTrigger('nextcloud.page.opened') as $campaign) { + if ($campaign['presentation']!=='none' + && $this->campaigns->isEligible($campaign, $uid, time()) + && $this->matchesPlace($campaign, $app, $pagePath)) { + $this->events->record($uid, 'nextcloud.page.opened', 'campaign_' . $campaign['id']); + } + } + } + // An immediate publication may also use a banner or a modal. + // Its outbound channels are queued at activation, while the browser + // presentation becomes eligible on the next visit. Do not produce + // ephemeral UI occurrences for a channel-only/inbox-only publication. + foreach ($this->campaigns->activeForTrigger('nextcloud.board.published') as $campaign) { + if (!in_array($campaign['presentation'],['banner','modal'],true) + || !$this->campaigns->isEligible($campaign,$uid,time()) + || ($campaign['category']==='sponsored' && $this->sponsoredOptout($uid)) + || !$this->matchesPlace($campaign,$app,$pagePath)) { continue; } + $key='immediate:'.$campaign['id'].':'.$campaign['published_at'].':'.gmdate('Y-m-d'); + $this->events->record($uid,'nextcloud.board.published', + 'campaign_'.$campaign['id'],$key); + } + foreach ($this->campaigns->activeForTrigger('nextcloud.time.window') as $campaign) { + if ($campaign['presentation']!=='none' + && $this->campaigns->isEligible($campaign, $uid, time()) && $this->matchesPlace($campaign, $app)) { + $key = 'window:' . $campaign['id'] . ':' . $campaign['updated_at'] . ':' . gmdate('Y-m-d'); + $this->events->record($uid, 'nextcloud.time.window', $app, $key); + } + } + + $byTrigger = []; + // Scan the recent event log in batches. No file paths or document content + // are stored in the event or returned to the browser. + foreach ($this->events->forUser($uid, time() - EventStore::RETENTION_SECONDS, 300) as $event) { + $trigger = $event['trigger_key']; + if (!isset($byTrigger[$trigger])) { + $byTrigger[$trigger] = $this->campaigns->activeForTrigger($trigger); + } + foreach ($byTrigger[$trigger] as $campaign) { + if ($campaign['presentation']==='none') { continue; } + if ($event['created_at'] < max($campaign['created_at'], $campaign['published_at'])) { continue; } + if ($campaign['starts_at'] !== null && $event['created_at'] < $campaign['starts_at']) { continue; } + if (!$this->campaigns->isEligible($campaign, $uid, time())) { continue; } + if ($campaign['category'] === 'sponsored' && $this->sponsoredOptout($uid)) { continue; } + if (!$this->matchesPlace($campaign, $app, $pagePath)) { continue; } + if (in_array($trigger,['nextcloud.page.opened','nextcloud.board.published'],true) + && $event['context_app'] !== 'campaign_' . $campaign['id']) { continue; } + if (in_array($trigger, ['nextcloud.flow.matched','external.webhook'], true) + && $event['context_app'] !== 'campaign_' . $campaign['id']) { continue; } + $this->ensureDelivery($event['id'], $campaign['id'], $uid); + } + } + } + + private function sponsoredOptout(string $uid): bool { + return $this->config->getUserValue($uid, 'communications', 'sponsored_optout', '0') === '1'; + } + + private function matchesPlace(array $campaign, string $app, string $pagePath = ''): bool { + $mode = $campaign['app_target_mode'] ?? 'legacy'; + $choices = $campaign['app_target_apps'] ?? []; + if ($campaign['trigger_key'] === 'nextcloud.page.opened' || $mode === 'legacy') { + if ($campaign['placement'] !== 'any' && $campaign['placement'] !== $app) { return false; } + } elseif ($mode === 'only') { + if ($app === 'any' || !in_array($app,$choices,true)) { return false; } + } elseif ($mode === 'exclude') { + if ($app === 'any' || in_array($app,$choices,true)) { return false; } + } + if ($campaign['trigger_key'] === 'nextcloud.page.opened') { + return $app !== 'any' && $this->webRoutes->matches( + $campaign['page_route'] ?? '', $app, $pagePath, $campaign['page_filters'] ?? [] + ); + } + return true; + } + + private function ensureDelivery(int $eventId, int $campaignId, string $uid): void { + $qb = $this->db->getQueryBuilder(); + $rows = [ + 'event_id' => $eventId, 'campaign_id' => $campaignId, 'uid' => $uid, + 'state' => 'pending', 'claim_token' => '', 'claim_until' => 0, + 'created_at' => time(), + ]; + foreach ($rows as $column => $value) { + $rows[$column] = $qb->createNamedParameter($value, is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR); + } + try { + $qb->insert(self::TABLE)->values($rows)->executeStatement(); + } catch (DbException $error) { + if (!in_array($error->getReason(), [DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION, DbException::REASON_CONSTRAINT_VIOLATION], true)) { + throw $error; + } + // Another tab/process already created this delivery. + } + } + + public function pending(string $uid, string $app = 'any', int $limit = 30, string $pagePath = ''): array { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('d.*', 'e.event_uuid', 'e.trigger_key', 'e.created_at AS event_created_at') + ->from(self::TABLE, 'd') + ->innerJoin('d', EventStore::TABLE, 'e', 'e.id = d.event_id') + ->where($qb->expr()->eq('d.uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->in('d.state', $qb->createNamedParameter(['pending', 'claimed'], IQueryBuilder::PARAM_STR_ARRAY))) + // Prefer recently queued deliveries. An ever-growing backlog must not + // permanently hide new campaigns behind the first 250 pending records. + ->orderBy('d.id', 'DESC') + ->setMaxResults(250) + ->executeQuery(); + $rows = $rs->fetchAllAssociative(); + $rs->closeCursor(); + $now = time(); + $out = []; + foreach ($rows as $delivery) { + $campaign = $this->campaigns->find((int)$delivery['campaign_id']); + if ($campaign === null || $campaign['presentation']==='none' + || !$this->campaigns->isEligible($campaign, $uid, $now)) { continue; } + // A campaign may have been edited after an event was queued. Never + // deliver an old trigger under a newly selected campaign trigger. + if ($delivery['trigger_key'] !== $campaign['trigger_key']) { continue; } + if ((int)$delivery['event_created_at'] < $campaign['published_at']) { continue; } + if ($campaign['category'] === 'sponsored' && $this->sponsoredOptout($uid)) { continue; } + if (!$this->matchesPlace($campaign, $app, $pagePath)) { continue; } + if ($delivery['state'] === 'claimed' && (int)$delivery['claim_until'] >= $now) { continue; } + if (!$this->isWithinFrequency($campaign, $uid, (int)$delivery['id'], $now)) { continue; } + $out[] = [ + ...$this->presentation($campaign, $uid), + 'deliveryId' => (int)$delivery['id'], + 'eventId' => $delivery['event_uuid'], + ]; + } + // With equal priority, prefer recent events: a large backlog must never + // starve a newly triggered campaign outside the bounded response. + usort($out, static fn (array $a, array $b): int => + ($a['manual_order'] <=> $b['manual_order']) ?: ($b['priority'] <=> $a['priority']) ?: ($b['deliveryId'] <=> $a['deliveryId'])); + // Return only the newest eligible occurrence per campaign in this scan. + // Otherwise hundreds of queued events from one high-priority campaign can + // fill the response and indefinitely hide other active campaigns. + $seen = []; + $result = []; + foreach ($out as $item) { + if (isset($seen[$item['campaignId']])) { continue; } + $seen[$item['campaignId']] = true; + $result[] = $item; + if (count($result) >= $limit) { break; } + } + return $result; + } + + private function isWithinFrequency(array $campaign, string $uid, int $deliveryId, int $now): bool { + $frequency = $campaign['frequency']; + $id = (int)$campaign['id']; + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('impression_at', 'state', 'claim_until')->from(self::TABLE) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('campaign_id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $rows = $rs->fetchAllAssociative(); + $rs->closeCursor(); + $views = []; + foreach ($rows as $row) { + if ($row['state'] === 'claimed' && (int)$row['claim_until'] >= $now) { return false; } + if ($row['impression_at'] !== null) { $views[] = (int)$row['impression_at']; } + } + if ($frequency === 'once' && count($views) > 0) { return false; } + if ($campaign['max_total'] > 0 && count($views) >= $campaign['max_total']) { return false; } + if ($campaign['max_per_day'] > 0 + && count(array_filter($views, fn (int $at): bool => $at >= $now - 86400)) >= $campaign['max_per_day']) { + return false; + } + if ($views !== []) { + $last = max($views); + $minimum = match ($frequency) { + 'daily' => 86400, 'weekly' => 604800, + 'custom' => $campaign['min_interval_minutes'] * 60, + default => 0, + }; + if ($minimum > 0 && $now - $last < $minimum) { return false; } + } + return true; + } + + private function reserveCampaign(string $uid, int $campaignId, int $deliveryId, string $token, int $now): bool { + $insert = $this->db->getQueryBuilder(); + try { + $insert->insert('cc_leases')->values([ + 'uid' => $insert->createNamedParameter($uid), + 'campaign_id' => $insert->createNamedParameter($campaignId, IQueryBuilder::PARAM_INT), + 'delivery_id' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'token' => $insert->createNamedParameter(''), + 'expires_at' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + ])->executeStatement(); + } catch (DbException $e) { + if (!in_array($e->getReason(), [DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION, DbException::REASON_CONSTRAINT_VIOLATION], true)) { + throw $e; + } + } + $qb = $this->db->getQueryBuilder(); + return $qb->update('cc_leases') + ->set('delivery_id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT)) + ->set('token', $qb->createNamedParameter($token)) + ->set('expires_at', $qb->createNamedParameter($now + self::CLAIM_TTL, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('campaign_id', $qb->createNamedParameter($campaignId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->lte('expires_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))) + ->executeStatement() === 1; + } + + private function releaseCampaign(string $uid, int $campaignId, string $token): void { + $qb = $this->db->getQueryBuilder(); + $qb->update('cc_leases') + ->set('expires_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('campaign_id', $qb->createNamedParameter($campaignId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->executeStatement(); + } + + public function claim(string $uid, string $app, bool $newVisit, string $pagePath = ''): ?array { + $this->prepare($uid, $app, $newVisit, $pagePath); + foreach ($this->pending($uid, $app, 30, $pagePath) as $item) { + $token = bin2hex(random_bytes(24)); + $now = time(); + if (!$this->reserveCampaign($uid, $item['campaignId'], $item['deliveryId'], $token, $now)) { continue; } + $qb = $this->db->getQueryBuilder(); + $changed = $qb->update(self::TABLE) + ->set('state', $qb->createNamedParameter('claimed')) + ->set('claim_token', $qb->createNamedParameter($token)) + ->set('claim_until', $qb->createNamedParameter($now + self::CLAIM_TTL, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($item['deliveryId'], IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->orX( + $qb->expr()->eq('state', $qb->createNamedParameter('pending')), + $qb->expr()->andX( + $qb->expr()->eq('state', $qb->createNamedParameter('claimed')), + $qb->expr()->lt('claim_until', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ) + )) + ->executeStatement(); + if ($changed === 1) { + $item['claimToken'] = $token; + return $item; + } + $this->releaseCampaign($uid, $item['campaignId'], $token); + } + return null; + } + + /** + * Claim up to five independent banners while keeping modals exclusive. + * Each banner retains its own user/campaign lease and claimToken. + * The original claim() contract is retained for older API consumers. + */ + public function claimBatch(string $uid,string $app,bool $newVisit,string $pagePath='',int $limit=5):array { + $this->prepare($uid,$app,$newVisit,$pagePath); + $candidates=$this->pending($uid,$app,30,$pagePath); + foreach ($candidates as $candidate) { + if ($candidate['presentation']!=='modal') {continue;} + $claimed=$this->reservePendingItem($uid,$candidate); + if ($claimed!==null) {return ['campaign'=>$claimed,'campaigns'=>[]];} + } + $claimed=[]; + foreach ($candidates as $candidate) { + if ($candidate['presentation']!=='banner') {continue;} + $item=$this->reservePendingItem($uid,$candidate); + if ($item!==null) {$claimed[]=$item;} + if (count($claimed)>=max(1,min($limit,5))) {break;} + } + return ['campaign'=>null,'campaigns'=>$claimed]; + } + + private function reservePendingItem(string $uid,array $item):?array { + $token=bin2hex(random_bytes(24)); + $now=time(); + if (!$this->reserveCampaign($uid,(int)$item['campaignId'],(int)$item['deliveryId'],$token,$now)) {return null;} + $qb=$this->db->getQueryBuilder(); + $changed=$qb->update(self::TABLE) + ->set('state',$qb->createNamedParameter('claimed')) + ->set('claim_token',$qb->createNamedParameter($token)) + ->set('claim_until',$qb->createNamedParameter($now+self::CLAIM_TTL,IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id',$qb->createNamedParameter((int)$item['deliveryId'],IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid',$qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->orX( + $qb->expr()->eq('state',$qb->createNamedParameter('pending')), + $qb->expr()->andX( + $qb->expr()->eq('state',$qb->createNamedParameter('claimed')), + $qb->expr()->lt('claim_until',$qb->createNamedParameter($now,IQueryBuilder::PARAM_INT)) + ) + ))->executeStatement(); + if ($changed===1) { + $item['claimToken']=$token; + return $item; + } + $this->releaseCampaign($uid,(int)$item['campaignId'],$token); + return null; + } + + public function presentation(array $c, string $uid): array { + $lang = $this->config->getUserValue($uid, 'core', 'lang', ''); + $c = CampaignRepository::localize($c, $lang); + return [ + 'campaignId' => $c['id'], 'trigger' => $c['trigger_key'], + 'title' => $c['headline'], 'message' => $c['message'], + 'presentation' => $c['presentation'], 'priority' => $c['priority'], + 'manual_order' => $c['manual_order'], 'dismissible' => $c['dismissible'], + 'text_align' => $c['text_align'], 'text_color' => $c['text_color'], + 'ctaLabel' => $c['cta_label'], 'ctaUrl' => $c['cta_url'], + 'category' => $c['category'], 'theme' => $c['theme'], + 'banner_color' => $c['banner_color'], 'banner_icon' => $c['banner_icon'], + ]; + } + + private function getForUser(int $deliveryId, string $uid): ?array { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('*')->from(self::TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->executeQuery(); + $row = $rs->fetchAssociative(); + $rs->closeCursor(); + return $row === false ? null : $row; + } + + public function transition(string $uid, int $id, string $token, string $action): bool { + if (!in_array($action, ['impression', 'dismiss', 'click'], true)) { return false; } + $row = $this->getForUser($id, $uid); + if ($row === null || $row['claim_token'] === '' || !hash_equals($row['claim_token'], $token)) { + return false; + } + $now = time(); + // Reading a long modal can take longer than the claim lease. Once it was + // recorded as shown, the same token may still close/click it; this cannot + // be reclaimed by another tab because 'shown' is not a pending state. + if ((int)$row['claim_until'] < $now + && !($row['state'] === 'shown' && in_array($action, ['dismiss', 'click'], true))) { + return false; + } + $qb = $this->db->getQueryBuilder(); + $qb->update(self::TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('claim_token', $qb->createNamedParameter($token))); + if ($action === 'impression') { + if (!in_array($row['state'], ['claimed', 'shown'], true)) { return false; } + if ($row['impression_at'] !== null) { return true; } + $qb->set('state', $qb->createNamedParameter('shown')) + ->set('impression_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter('claimed'))); + } elseif ($action === 'dismiss') { + if (!in_array($row['state'], ['claimed', 'shown', 'dismissed', 'clicked'], true)) { return false; } + if ($row['dismissed_at'] !== null) { return true; } + $qb->set('state', $qb->createNamedParameter('dismissed')) + ->set('dismissed_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)); + } else { + if (!in_array($row['state'], ['claimed', 'shown', 'dismissed', 'clicked'], true)) { return false; } + if ($row['clicked_at'] !== null) { return true; } + $qb->set('state', $qb->createNamedParameter('clicked')) + ->set('clicked_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->set('dismissed_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)); + } + $changed = $qb->executeStatement() === 1; + if ($changed && $action !== 'impression') { + $this->releaseCampaign($uid, (int)$row['campaign_id'], $token); + } + return $changed; + } + + /** Legacy API compatibility. Only the user's own queued event can be acknowledged. */ + public function legacyAcknowledge(string $uid, int $campaignId, string $eventUuid): bool { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('d.id', 'd.state')->from(self::TABLE, 'd') + ->innerJoin('d', EventStore::TABLE, 'e', 'e.id = d.event_id') + ->where($qb->expr()->eq('d.uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('d.campaign_id', $qb->createNamedParameter($campaignId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('e.event_uuid', $qb->createNamedParameter($eventUuid))) + ->executeQuery(); + $row = $rs->fetchAssociative(); + $rs->closeCursor(); + if ($row === false) { return false; } + if (in_array($row['state'], ['dismissed', 'clicked'], true)) { return true; } + if (!in_array($row['state'], ['pending', 'claimed', 'shown'], true)) { return false; } + $qb = $this->db->getQueryBuilder(); + return $qb->update(self::TABLE) + ->set('state', $qb->createNamedParameter('dismissed')) + ->set('dismissed_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$row['id'], IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->executeStatement() === 1; + } + + public function metrics(int $campaignId): array { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('state', 'impression_at', 'dismissed_at', 'clicked_at') + ->from(self::TABLE) + ->where($qb->expr()->eq('campaign_id', $qb->createNamedParameter($campaignId, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $rows = $rs->fetchAllAssociative(); + $rs->closeCursor(); + return [ + 'estimatedImpressions' => count(array_filter($rows, fn (array $r): bool => $r['impression_at'] !== null)), + 'dismissals' => count(array_filter($rows, fn (array $r): bool => $r['dismissed_at'] !== null)), + 'clicks' => count(array_filter($rows, fn (array $r): bool => $r['clicked_at'] !== null)), + 'queuedOrClaimed' => count(array_filter($rows, fn (array $r): bool => in_array($r['state'], ['pending','claimed'], true))), + ]; + } + + public function prune(int $before): int { + $leases = $this->db->getQueryBuilder(); + $leases->delete('cc_leases') + ->where($leases->expr()->lt('expires_at', $leases->createNamedParameter(time() - 86400, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + $qb = $this->db->getQueryBuilder(); + return $qb->delete(self::TABLE)->where($qb->expr()->lt('created_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_INT)))->executeStatement(); + } +} diff --git a/lib/Service/EditorAccess.php b/lib/Service/EditorAccess.php new file mode 100644 index 0000000..ff87a7d --- /dev/null +++ b/lib/Service/EditorAccess.php @@ -0,0 +1,53 @@ +groups->isAdmin($uid); + } + /** @return list */ + public function groups():array { + $value=json_decode($this->config->getValueString(self::APP,self::KEY,'[]'),true); + if (!is_array($value) || !array_is_list($value)) { return []; } + return array_values(array_filter($value,fn($id):bool => + is_string($id) && preg_match('/^[A-Za-z0-9_.:@-]{1,64}$/D',$id)===1 + && $this->groups->get($id)!==null)); + } + public function canEdit(?string $uid):bool { + if ($uid===null) { return false; } + if ($this->groups->isAdmin($uid)) { return true; } + foreach ($this->groups() as $gid) { + if ($this->groups->isInGroup($uid,$gid)) { return true; } + } + return false; + } + /** @param list $items */ + public function configure(?string $actor,array $items):array { + if (!$this->isAdmin($actor)) { throw new InvalidArgumentException('Somente administradores podem delegar edição.'); } + if (!array_is_list($items) || count($items)>20) { + throw new InvalidArgumentException('Escolha no máximo vinte grupos editoriais.'); + } + $selected=[]; + foreach ($items as $id) { + if (!is_string($id) || !preg_match('/^[A-Za-z0-9_.:@-]{1,64}$/D',$id) + || $this->groups->get($id)===null) { + throw new InvalidArgumentException('Grupo editorial não encontrado.'); + } + $selected[$id]=$id; + } + $items=array_values($selected); + $this->config->setValueString(self::APP,self::KEY,json_encode($items,JSON_THROW_ON_ERROR)); + return $items; + } +} diff --git a/lib/Service/EventStore.php b/lib/Service/EventStore.php new file mode 100644 index 0000000..ac1d085 --- /dev/null +++ b/lib/Service/EventStore.php @@ -0,0 +1,144 @@ +campaigns->activeForTrigger($event['trigger_key']) as $campaign) { + if ($campaign['board_visible'] || $campaign['notify_users'] || $campaign['notify_activity'] + || $campaign['notify_email'] || $campaign['gateway_channels']!==[]) { + $this->jobs->add(EventChannelDeliveryJob::class,['eventId'=>(int)$event['id']]); + return; + } + } + } + + /** Trusted worker lookup; event ID is never accepted directly from a client. */ + public function byId(int $id):?array { + if ($id<=0) { return null; } + $qb=$this->db->getQueryBuilder(); + $rs=$qb->select('id','event_uuid','uid','trigger_key','context_app','created_at') + ->from(self::TABLE) + ->where($qb->expr()->eq('id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $row=$rs->fetchAssociative();$rs->closeCursor(); + return $row===false?null:$this->cast($row); + } + + /** + * @return array{id:int, event_uuid:string, uid:string, trigger_key:string, context_app:string, created_at:int} + */ + public function record(string $uid, string $trigger, string $contextApp = 'any', ?string $idempotencyKey = null): array { + if ($uid === '' || mb_strlen($uid) > 64 || $trigger === '' || mb_strlen($trigger) > 64) { + throw new \InvalidArgumentException('Invalid event subject.'); + } + if (!preg_match('/^[a-z0-9_-]{1,32}$/', $contextApp)) { + throw new \InvalidArgumentException('Invalid app context.'); + } + // Hash the caller's external ID to avoid persisting untrusted identifiers. + $dedupeKey = $idempotencyKey === null + ? bin2hex(random_bytes(32)) + : hash('sha256', $uid . "\0" . $trigger . "\0" . $idempotencyKey); + $existing = $this->byDedupeKey($dedupeKey); + if ($existing !== null) { + // A webhook replay may recover a job submission that had failed. + $this->scheduleChannels($existing); + return $existing; + } + $hex = bin2hex(random_bytes(16)); + $uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); + $data = [ + 'event_uuid' => $uuid, + 'dedupe_key' => $dedupeKey, + 'trigger_key' => $trigger, + 'uid' => $uid, + 'context_app' => $contextApp, + 'created_at' => time(), + ]; + try { + $qb = $this->db->getQueryBuilder(); + $args = []; + foreach ($data as $col => $val) { + $args[$col] = $qb->createNamedParameter($val, is_int($val) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR); + } + $qb->insert(self::TABLE)->values($args)->executeStatement(); + } catch (DbException $e) { + if (!in_array($e->getReason(), [DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION, DbException::REASON_CONSTRAINT_VIOLATION], true)) { throw $e; } + $existing = $this->byDedupeKey($dedupeKey); + if ($existing !== null) { return $existing; } + throw $e; + } + $data['id'] = (int)$this->db->lastInsertId(self::TABLE); + $this->scheduleChannels($data); + return $data; + } + + public function forUser(string $uid, int $since, int $limit = 120): array { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('id', 'event_uuid', 'trigger_key', 'uid', 'context_app', 'created_at') + ->from(self::TABLE) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->gte('created_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'DESC')->setMaxResults($limit)->executeQuery(); + $items = $rs->fetchAllAssociative(); + $rs->closeCursor(); + return array_map($this->cast(...), $items); + } + + public function byIdForUser(int $id, string $uid): ?array { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('id', 'event_uuid', 'trigger_key', 'uid', 'context_app', 'created_at') + ->from(self::TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->executeQuery(); + $row = $rs->fetchAssociative(); + $rs->closeCursor(); + return $row === false ? null : $this->cast($row); + } + + private function byDedupeKey(string $key): ?array { + $qb = $this->db->getQueryBuilder(); + $rs = $qb->select('*')->from(self::TABLE) + ->where($qb->expr()->eq('dedupe_key', $qb->createNamedParameter($key))) + ->executeQuery(); + $row = $rs->fetchAssociative(); + $rs->closeCursor(); + return $row === false ? null : $this->cast($row); + } + + private function cast(array $row): array { + $row['id'] = (int)$row['id']; + $row['created_at'] = (int)$row['created_at']; + return $row; + } + + public function prune(int $before): int { + $qb = $this->db->getQueryBuilder(); + // Parent records will only be deleted after deliveries are removed by CleanupJob. + return $qb->delete(self::TABLE)->where($qb->expr()->lt('created_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_INT)))->executeStatement(); + } +} diff --git a/lib/Service/FlowCampaignOperation.php b/lib/Service/FlowCampaignOperation.php new file mode 100644 index 0000000..7c4f461 --- /dev/null +++ b/lib/Service/FlowCampaignOperation.php @@ -0,0 +1,69 @@ +urls->imagePath('communications', 'app.svg'); } + public function isAvailableForScope(int $scope): bool { return $scope === IManager::SCOPE_ADMIN; } + public function getTriggerHint(): string { return 'Um evento de arquivo cumpre uma regra de campanha'; } + + public function validateOperation(string $name, array $checks, string $operation): void { + if (!ctype_digit($operation) || (int)$operation <= 0) { + throw new \UnexpectedValueException('Informe o ID numérico da campanha.'); + } + $campaign = $this->campaigns->find((int)$operation); + if ($campaign === null || $campaign['trigger_key'] !== 'nextcloud.flow.matched') { + throw new \UnexpectedValueException('A campanha deve existir e usar o gatilho Nextcloud Flow.'); + } + } + + public function onEvent(string $eventName, Event $event, IRuleMatcher $matcher): void { + // The Flow engine's file entity dispatches legacy GenericEvent objects, + // not modern NodeCreatedEvent/NodeWrittenEvent. Use the event contract the + // registered entity actually provides, after Flow has evaluated its checks. + if (!$event instanceof GenericEvent || !in_array($eventName, ['\\OCP\\Files::postCreate', '\\OCP\\Files::postWrite'], true)) { return; } + $node = $event->getSubject(); + if (!$node instanceof File) { return; } + $owner = $node->getOwner(); + if ($owner === null || !str_starts_with($node->getPath(), '/' . $owner->getUID() . '/files/')) { + return; + } + try { + foreach ($matcher->getFlows(false) as $rule) { + $id = (int)($rule['operation'] ?? 0); + $campaign = $this->campaigns->find($id); + if ($campaign === null || $campaign['trigger_key'] !== 'nextcloud.flow.matched') { continue; } + $this->events->record($owner->getUID(), 'nextcloud.flow.matched', 'campaign_' . $id); + } + } catch (\Throwable $e) { + $this->logger->warning('Flow campaign rule failed without stopping Files', ['exception'=>$e]); + } + } +} diff --git a/lib/Service/GatewayRecipientPreferences.php b/lib/Service/GatewayRecipientPreferences.php new file mode 100644 index 0000000..23d75f4 --- /dev/null +++ b/lib/Service/GatewayRecipientPreferences.php @@ -0,0 +1,95 @@ +190) { return false; } + return match ($gateway) { + 'sms','signal','gowhatsapp','whatsappbusiness' + => preg_match('/^\+[1-9][0-9]{6,14}$/D',$destination)===1, + 'telegram' + => preg_match('/^[1-9][0-9]{4,19}$/D',$destination)===1, + 'xmpp' + => preg_match('/^[A-Za-z0-9_.+-]{1,64}@[A-Za-z0-9.-]{3,120}$/D',$destination)===1 + && str_contains(substr($destination,strpos($destination,'@')+1),'.'), + default => false, + }; + } + + /** @return array */ + public function forUser(string $uid): array { + $raw=$this->config->getUserValue($uid,self::APP,self::KEY,''); + if ($raw==='') { return []; } + $decoded=json_decode($raw,true); + if (!is_array($decoded) || array_is_list($decoded)) { return []; } + $result=[]; + foreach ($decoded as $gateway=>$entry) { + if (is_string($gateway) && isset(GatewayTransport::LABELS[$gateway]) + && is_array($entry) && is_string($entry['destination']??null) + && self::validDestination($gateway,$entry['destination'])) { + $result[$gateway]=[ + 'destination'=>$entry['destination'], + 'sponsored'=>($entry['sponsored']??false)===true, + ]; + } + } + return $result; + } + + /** @return array */ + public function replace(string $uid,array $entries): array { + if (count($entries)>count(GatewayTransport::LABELS) || array_is_list($entries) && $entries!==[]) { + throw new \InvalidArgumentException('Invalid messaging preferences'); + } + $saved=[]; + foreach ($entries as $gateway=>$entry) { + if (!is_string($gateway) || !isset(GatewayTransport::LABELS[$gateway]) + || !is_array($entry) || array_is_list($entry) || array_diff(array_keys($entry),['enabled','destination','sponsored'])!==[]) { + throw new \InvalidArgumentException('Invalid messaging gateway preference'); + } + $enabled=$entry['enabled']??false; + $sponsored=$entry['sponsored']??false; + if (!is_bool($enabled) || !is_bool($sponsored) + || !is_string($entry['destination']??null)) { + throw new \InvalidArgumentException('Messaging consent must be explicit'); + } + if (!$enabled) { continue; } + if (!self::validDestination($gateway,$entry['destination'])) { + throw new \InvalidArgumentException('Invalid destination for '.$gateway); + } + $saved[$gateway]=[ + 'destination'=>$entry['destination'], 'sponsored'=>$sponsored, + ]; + } + if ($saved===[]) { + $this->config->deleteUserValue($uid,self::APP,self::KEY); + } else { + $this->config->setUserValue($uid,self::APP,self::KEY,json_encode($saved,JSON_THROW_ON_ERROR)); + } + return $saved; + } + + public function destinationFor(string $uid,string $gateway,string $category): ?string { + $item=$this->forUser($uid)[$gateway]??null; + if ($item===null || ($category==='sponsored' && !$item['sponsored'])) { return null; } + return $item['destination']; + } +} diff --git a/lib/Service/GatewayTransport.php b/lib/Service/GatewayTransport.php new file mode 100644 index 0000000..8eed900 --- /dev/null +++ b/lib/Service/GatewayTransport.php @@ -0,0 +1,91 @@ + 'SMS', + 'signal' => 'Signal', + 'telegram' => 'Telegram', + 'gowhatsapp' => 'WhatsApp', + 'whatsappbusiness' => 'WhatsApp Business', + 'xmpp' => 'XMPP', + ]; + + public function __construct( + private ContainerInterface $container, + private IAppManager $apps, + private LoggerInterface $logger, + ) {} + + public function installed(): bool { + return $this->apps->isEnabledForAnyone(self::APP); + } + + private function service(): object { + if (!$this->installed()) { + throw new \RuntimeException('Two-Factor Gateway is not enabled'); + } + $service=$this->container->get(self::SERVICE); + if (!is_object($service) || !is_callable([$service,'ensureAvailable']) + || !is_callable([$service,'isGatewayComplete']) || !is_callable([$service,'send'])) { + throw new \RuntimeException('Two-Factor Gateway direct integration is unavailable'); + } + return $service; + } + + public function configured(string $gateway): bool { + if (!isset(self::LABELS[$gateway]) || !$this->installed()) { return false; } + try { + return $this->service()->isGatewayComplete($gateway) === true; + } catch (\Throwable $error) { + // Never log destination identifiers, provider credentials, or exception text. + $this->logger->debug('Gateway capability check failed', [ + 'app'=>'communications', + 'gateway'=>$gateway, + 'exceptionClass'=>$error::class, + ]); + return false; + } + } + + /** @throws \RuntimeException */ + public function send(string $gateway,string $destination,string $body): void { + if (!isset(self::LABELS[$gateway])) { + throw new \InvalidArgumentException('Unknown messaging gateway'); + } + if (!GatewayRecipientPreferences::validDestination($gateway,$destination)) { + throw new \InvalidArgumentException('Invalid messaging destination'); + } + if (trim($body)==='' || mb_strlen($body)>5000) { + throw new \InvalidArgumentException('Invalid messaging body'); + } + $service=$this->service(); + $service->ensureAvailable($gateway); + if ($service->isGatewayComplete($gateway) !== true) { + throw new \RuntimeException('Messaging gateway is not configured'); + } + try { + $service->send($gateway,$destination,$body); + } catch (\Throwable $error) { + // Provider may have accepted the message before losing its response. + throw new DeliveryOutcomeUnknownException('Gateway outcome unknown',0,$error); + } + } +} diff --git a/lib/Service/NativeEventCatalog.php b/lib/Service/NativeEventCatalog.php new file mode 100644 index 0000000..257d18a --- /dev/null +++ b/lib/Service/NativeEventCatalog.php @@ -0,0 +1,186 @@ + */ + public function all(): array { + if ($this->memo !== null) { return $this->memo; } + $enabled = $this->apps->getEnabledApps(); + $revisionData = ['core' => (int)@filemtime(\OC::$SERVERROOT . '/version.php')]; + foreach ($enabled as $app) { + if ($app === self::APP_ID) { continue; } + $info = $this->apps->getAppInfo($app); + $revisionData[$app] = [ + 'version'=>is_array($info) ? (string)($info['version'] ?? '') : '', + // Development builds may publish new typed events without changing + // info.xml. Inspect event class bytes on ADMIN catalog requests only. + 'eventClasses'=>$this->eventClassFingerprint($app), + ]; + } + ksort($revisionData); + $revision = hash('sha256', json_encode($revisionData, JSON_THROW_ON_ERROR)); + if ($this->config->getValueString(self::APP_ID, self::REV_KEY, '') === $revision) { + $stored = json_decode($this->config->getValueString(self::APP_ID, self::INDEX_KEY, ''), true); + if (is_array($stored) && array_is_list($stored)) { + return $this->memo = $stored; + } + } + $events = []; + // Core public events are present even when a corresponding UI app is absent. + $this->scan(\OC::$SERVERROOT . '/lib/public', 'OCP', 'core', $events, true); + foreach ($enabled as $app) { + if ($app === self::APP_ID || !preg_match('/^[a-z0-9_-]{2,32}$/', $app)) { continue; } + try { + $path = $this->apps->getAppPath($app); + $namespace = $this->apps->getAppNamespace($app); + foreach (['Events', 'Event'] as $dir) { + $this->scan($path . '/lib/' . $dir, $namespace . '\\' . $dir, $app, $events, false); + } + } catch (\Throwable) { + // A broken third-party app may not prevent campaign configuration. + } + if (count($events) >= self::MAX_EVENTS) { break; } + } + $events = array_values($events); + usort($events, static fn (array $a, array $b): int => + strcmp($a['appId'], $b['appId']) ?: strcmp($a['class'], $b['class'])); + // Write the complete index before the revision marker so an interrupted + // write cannot make an incomplete catalog appear current. + $this->config->setValueString(self::APP_ID, self::INDEX_KEY, json_encode($events, JSON_THROW_ON_ERROR)); + $this->config->setValueString(self::APP_ID, self::REV_KEY, $revision); + return $this->memo = $events; + } + + public function byKey(string $key): ?array { + if (!preg_match('/^php_event\.[a-f0-9]{24}$/D', $key)) { return null; } + foreach ($this->all() as $entry) { + if ($entry['key'] === $key) { return $entry; } + } + return null; + } + + /** + * Resolve public OCP event families to their functional Nextcloud app. + * A core user event remains in "core"; never infer arbitrary actor/owner data. + */ + /** + * Tracks the event class inventory (including changed webhook interfaces) + * independently of the app version. Metadata only; no class instantiated. + * This runs when an administrator opens the catalog, never in the hot path + * of event dispatch. Bounded to supported Event/Events folders. + */ + private function eventClassFingerprint(string $app): string { + try { + $path=$this->apps->getAppPath($app); + $hashes=[]; + foreach (['Events','Event'] as $folder) { + $root=$path.'/lib/'.$folder; + if (!is_dir($root) || is_link($root)) { continue; } + $iterator=new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root,\FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $file) { + if (count($hashes)>=self::MAX_EVENTS) { break 2; } + if (!$file->isFile() || $file->isLink() || + !str_ends_with($file->getFilename(),'Event.php')) { continue; } + $rel=$folder.'/'.substr($file->getPathname(),strlen($root)+1); + $digest=@hash_file('sha256',$file->getPathname()); + if ($digest!==false) { $hashes[$rel]=$digest; } + } + } + ksort($hashes); + return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR)); + } catch (\Throwable) { + // Unavailable optional apps must not break the administrative editor. + return ''; + } + } + + private function coreApp(string $class): string { + return match (true) { + str_starts_with($class, 'OCP\\Files\\') => 'files', + str_starts_with($class, 'OCP\\Share\\') => 'files_sharing', + str_starts_with($class, 'OCP\\Comments\\') => 'comments', + str_starts_with($class, 'OCP\\Calendar\\') => 'dav', + default => 'core', + }; + } + + /** @param array $out */ + private function scan(string $directory, string $namespace, string $app, array &$out, bool $core): void { + if (!is_dir($directory) || is_link($directory)) { return; } + $root = realpath($directory); + if ($root === false) { return; } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $file) { + if (count($out) >= self::MAX_EVENTS) { return; } + if (!$file->isFile() || $file->isLink()) { continue; } + $fileName = $file->getFilename(); + if (!str_ends_with($fileName, 'Event.php')) { continue; } + $subPath = substr($file->getPathname(), strlen($root) + 1); + if ($subPath === '' || substr_count($subPath, '/') > 6) { continue; } + $part = substr($subPath, 0, -4); + if (!preg_match('~^[A-Za-z][A-Za-z0-9_]*(?:/[A-Za-z][A-Za-z0-9_]*)*$~D', $part)) { continue; } + // A few core files ending Event.php are not typed Event subclasses. + $class = $namespace . '\\' . str_replace('/', '\\', $part); + try { + if (!class_exists($class) || !is_subclass_of($class, Event::class)) { continue; } + $reflection = new \ReflectionClass($class); + if ($reflection->isAbstract() || $reflection->isInterface()) { continue; } + $appId = $core ? $this->coreApp($class) : $app; + if ($appId !== 'core' && !$this->apps->isEnabledForAnyone($appId)) { continue; } + $short = $reflection->getShortName(); + $phase = str_starts_with($short, 'Before') ? 'before' : 'unknown'; + $webhook = is_a($class, \OCP\EventDispatcher\IWebhookCompatibleEvent::class, true); + $description = ($phase === 'before' ? 'Antes da operação; não confirma sua conclusão. ' : + 'O momento do evento depende do aplicativo; não comprova, sozinho, o resultado da operação. ') + . 'Destinatário: usuário autenticado desta requisição, não necessariamente o afetado. ' + . (NativeEventSubscriptions::eventIsMutationByName($class) + ? 'Para preservar páginas rápidas, este gatilho ignora GET, HEAD e OPTIONS. ' : '') + . ($webhook ? 'Compatível com Webhook Listeners.' : 'Evento PHP interno.'); + $key = self::triggerKey($class); + $out[$class] = [ + 'key' => $key, 'appId' => $appId, 'class' => $class, + 'label' => 'Evento PHP: ' . $short, + 'description' => $description, + 'webhook' => $webhook, 'phase' => $phase, + ]; + } catch (\Throwable) { + // Discovery of other apps must never affect Nextcloud's normal operation. + } + } + } +} diff --git a/lib/Service/NativeEventSubscriptions.php b/lib/Service/NativeEventSubscriptions.php new file mode 100644 index 0000000..a106cc7 --- /dev/null +++ b/lib/Service/NativeEventSubscriptions.php @@ -0,0 +1,132 @@ + */ + public static function bootstrapClasses(string $json, string $httpMethod = ''): array { + if ($json === '' || $json === '[]' || $json === '{}') { return []; } + $decoded = json_decode($json, true); + if (!is_array($decoded) || array_is_list($decoded)) { return []; } + $classes = []; + foreach ($decoded as $class => $meta) { + if (!is_string($class) || !is_array($meta) + || !preg_match('/^(?:OCP|OCA)\\\\[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*Event$/D', $class) + || !is_string($meta['key'] ?? null) + || !hash_equals(NativeEventCatalog::triggerKey($class), $meta['key'])) { continue; } + if (in_array(strtoupper($httpMethod), ['GET', 'HEAD', 'OPTIONS'], true) + && self::eventIsMutationByName($class)) { continue; } + $classes[] = $class; + if (count($classes) >= self::MAX_SUBSCRIPTIONS) { break; } + } + return $classes; + } + + /** @return array */ + public function active(): array { + if ($this->current !== null) { return $this->current; } + $raw = json_decode($this->config->getValueString(self::APP_ID, self::CONFIG_KEY, '{}'), true); + if (!is_array($raw) || array_is_list($raw)) { return $this->current = []; } + $active = []; + foreach ($raw as $class => $meta) { + if (!is_string($class) || !is_array($meta) || !is_string($meta['key'] ?? null) + || !is_string($meta['appId'] ?? null) + || !hash_equals(NativeEventCatalog::triggerKey($class), $meta['key']) + || !preg_match('/^[a-z0-9_-]{2,32}$/D', $meta['appId'])) { continue; } + if ($meta['appId'] !== 'core' && !$this->apps->isEnabledForAnyone($meta['appId'])) { continue; } + $active[$class] = ['key'=>$meta['key'], 'appId'=>$meta['appId'], 'class'=>$class]; + if (count($active) >= self::MAX_SUBSCRIPTIONS) { break; } + } + return $this->current = $active; + } + + public function forClass(string $class): ?array { + return $this->active()[$class] ?? null; + } + + /** Reject an excessive number of active event classes BEFORE DB mutation. */ + public function assertCapacity(string $trigger, string $status, ?int $excludeCampaignId = null): void { + if ($status !== 'active' || !str_starts_with($trigger, 'php_event.')) { return; } + $qb = $this->db->getQueryBuilder(); + $qb->select('trigger_key')->from('cc_campaigns') + ->where($qb->expr()->eq('status', $qb->createNamedParameter('active'))) + ->andWhere($qb->expr()->like('trigger_key', $qb->createNamedParameter('php_event.%'))) + ->groupBy('trigger_key'); + if ($excludeCampaignId !== null) { + $qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($excludeCampaignId))); + } + $result = $qb->executeQuery(); + $keys = $result->fetchFirstColumn(); + $result->closeCursor(); + if (!in_array($trigger, $keys, true) && count($keys) >= self::MAX_SUBSCRIPTIONS) { + throw new \InvalidArgumentException('Limite de 80 tipos de eventos ativos atingido. Desative alguma campanha antes de ativar outra origem.'); + } + } + + /** + * Rebuild after a create/update/pause/archive. Querying occurs only on + * management writes; disabled applications are never subscribed to. + */ + public function refresh(): void { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('trigger_key')->from('cc_campaigns') + ->where($qb->expr()->eq('status', $qb->createNamedParameter('active'))) + ->andWhere($qb->expr()->like('trigger_key', $qb->createNamedParameter('php_event.%'))) + ->groupBy('trigger_key') + ->setMaxResults(self::MAX_SUBSCRIPTIONS + 1) + ->executeQuery(); + $keys = $result->fetchFirstColumn(); + $result->closeCursor(); + if (count($keys) > self::MAX_SUBSCRIPTIONS) { + throw new \RuntimeException('Too many active dynamic event classes.'); + } + $entries = []; + foreach ($keys as $key) { + if (!is_string($key)) { continue; } + $source = $this->catalog->byKey($key); + if ($source === null) { continue; } + $entries[$source['class']] = [ + 'key' => $source['key'], 'appId' => $source['appId'], 'class' => $source['class'] + ]; + } + $this->config->setValueString(self::APP_ID, self::CONFIG_KEY, json_encode($entries, JSON_THROW_ON_ERROR)); + $this->current = $entries; + } +} diff --git a/lib/Service/PublicationLink.php b/lib/Service/PublicationLink.php new file mode 100644 index 0000000..8228f56 --- /dev/null +++ b/lib/Service/PublicationLink.php @@ -0,0 +1,23 @@ +linkToRoute('communications.page.board',['id'=>$id]); + if ($route==='') { + $route=rtrim((string)\OC::$WEBROOT,'/').'/index.php/apps/communications/board?id='.$id; + } else { + // Some routes ignore non-path parameters; a deep link always carries id. + if (!str_contains($route,'id=')) { + $route.=(str_contains($route,'?')?'&':'?').'id='.$id; + } + } + return $urls->getAbsoluteURL($route); + } +} diff --git a/lib/Service/TriggerCatalog.php b/lib/Service/TriggerCatalog.php new file mode 100644 index 0000000..d056fa5 --- /dev/null +++ b/lib/Service/TriggerCatalog.php @@ -0,0 +1,387 @@ +'app', 'label'=>'Em um aplicativo', 'description'=>'Ação realizada ou visita a uma página do aplicativo', 'icon'=>'application-outline'], + ['id'=>'schedule', 'label'=>'Em uma data ou período', 'description'=>'Durante uma janela de publicação', 'icon'=>'calendar-clock-outline'], + ['id'=>'board', 'label'=>'Ao ativar a publicação', 'description'=>'Enviar os canais de saída ao ativar; banners ou janelas aparecem na próxima visita elegível', 'icon'=>'bulletin-board'], + ['id'=>'advanced', 'label'=>'Regra avançada ou integração', 'description'=>'Usar condições do Flow ou um sistema externo', 'icon'=>'source-branch'], + ]; + } + + public function sources(): array { + $sources = [ + [ + 'id'=>'files', 'appId'=>'files', 'intent'=>'action', + 'label'=>'Arquivos', 'description'=>'Criação, atualização ou exclusão no Files', + 'keywords'=>['arquivo','documento','enviar','upload','alterar','excluir','pasta'], + 'actions'=>[ + ['key'=>'nextcloud.files.created','label'=>'Arquivo criado','description'=>'Depois que alguém enviar ou criar um arquivo'], + ['key'=>'nextcloud.files.updated','label'=>'Arquivo atualizado','description'=>'Depois de modificar um arquivo existente'], + ['key'=>'nextcloud.files.deleted','label'=>'Arquivo excluído','description'=>'Depois de excluir um arquivo'], + ], + ], + [ + 'id'=>'sharing', 'appId'=>'files_sharing', 'intent'=>'action', + 'label'=>'Compartilhamentos', 'description'=>'Criação ou aceitação de compartilhamentos', + 'keywords'=>['share','compartilhar','aceitar','destinatário','permissão'], + 'actions'=>[ + ['key'=>'nextcloud.share.created','label'=>'Compartilhamento criado','description'=>'Quando uma pessoa compartilhar um item'], + ['key'=>'nextcloud.share.accepted','label'=>'Compartilhamento aceito','description'=>'Quando uma pessoa aceitar um compartilhamento recebido'], + ], + ], + [ + 'id'=>'users', 'appId'=>'core', 'intent'=>'action', + 'label'=>'Acesso e contas', 'description'=>'Login de usuários autenticados', + 'keywords'=>['login','acesso','entrar','conta','boas-vindas'], + 'actions'=>[ + ['key'=>'nextcloud.user.login','label'=>'Login interativo','description'=>'Em cada entrada por formulário de login'], + ['key'=>'nextcloud.user.first_login','label'=>'Primeiro login','description'=>'Quando uma conta entrar pela primeira vez'], + ], + ], + [ + 'id'=>'comments', 'appId'=>'comments', 'intent'=>'action', + 'label'=>'Comentários', 'description'=>'Mensagens criadas nos comentários do Nextcloud', + 'keywords'=>['comentário','comentar','mensagem'], + 'actions'=>[ + ['key'=>'nextcloud.comment.created','label'=>'Comentário publicado','description'=>'Depois que alguém publicar um comentário'], + ], + ], + [ + 'id'=>'navigation', 'appId'=>'core', 'intent'=>'visit', + 'label'=>'Navegação', 'description'=>'Mostrar ao entrar em um aplicativo compatível', + 'keywords'=>['abrir','entrar','visitar','files','dashboard','página','aplicativo'], + 'actions'=>[ + ['key'=>'nextcloud.page.opened','label'=>'Página de aplicativo aberta', + 'description'=>'Na entrada da área selecionada em “Onde apresentar”'], + ], + ], + [ + 'id'=>'board', 'appId'=>'communications', 'intent'=>'board', + 'label'=>'Publicação imediata', 'description'=>'Entregar a mensagem aos destinatários sem aguardar um evento técnico', + 'keywords'=>['mural','comunicado','anúncio','publicar','notícia','histórico','imediato'], + 'actions'=>[ + ['key'=>'nextcloud.board.published','label'=>'Publicação imediata', + 'description'=>'Envio imediato em fila, com banner/modal na próxima visita e Caixa de entrada opcional'], + ], + ], + [ + 'id'=>'scheduler', 'appId'=>'core', 'intent'=>'schedule', + 'label'=>'Agendamento', 'description'=>'Disponível durante o período de publicação', + 'keywords'=>['data','agendado','período','hora','programar'], + 'actions'=>[ + ['key'=>'nextcloud.time.window','label'=>'Publicação disponível no período', + 'description'=>'Na próxima visita elegível durante as datas configuradas'], + ], + ], + [ + 'id'=>'flow', 'appId'=>'workflowengine', 'intent'=>'advanced', + 'label'=>'Nextcloud Flow', 'description'=>'Condições avançadas sobre arquivos e ações', + 'keywords'=>['regra','fluxo','mime','tipo','extensão','pasta','condição','automação'], + 'actionUrl'=>'/index.php/settings/admin/workflow', + 'setup'=>'Salve a campanha e, no Flow, crie uma regra de arquivos com condições. Escolha a operação “Disparar campanha contextual” e selecione esta campanha pelo nome.', + 'actions'=>[ + ['key'=>'nextcloud.flow.matched','label'=>'Uma regra do Flow foi atendida', + 'description'=>'O Flow verifica os filtros; o aplicativo só controla a entrega'], + ], + ], + ]; + if (strlen($this->config->getValueString('communications','webhook_secret','')) >= 32) { + $sources[] = [ + 'id'=>'webhook','appId'=>'communications','intent'=>'advanced', + 'label'=>'Webhook externo', 'description'=>'Um sistema autorizado informa um evento para um usuário específico', + 'keywords'=>['api','webhook','integração','externo','mautic','sistema'], + 'setup'=>'A integração envia uma chamada HMAC com campaignId (identificador DESTA campanha), uid (UM usuário do Nextcloud) e eventId (identificador único). Só esta campanha e este usuário recebem a ocorrência; as regras de público e frequência ainda são aplicadas. Não há disparo para todos os usuários. Consulte docs/API.md.', + 'actions'=>[ + ['key'=>'external.webhook','label'=>'Evento HMAC de campanha e usuário', + 'description'=>'Registrar a ocorrência apenas para campaignId e uid indicados no webhook'], + ], + ]; + } + + // Generic typed events are indexed only when an admin opens the catalog. + // These actions target the authenticated principal, never an inferred signer. + $nativeByApp = []; + foreach ($this->nativeEvents->all() as $native) { + $app = $native['appId']; + if (!isset($nativeByApp[$app])) { + $info = $app === 'core' ? null : $this->apps->getAppInfo($app); + $appLabel = is_array($info) && is_string($info['name'] ?? null) ? $info['name'] : $app; + $nativeByApp[$app] = [ + 'id'=>'auto_' . $app, 'appId'=>$app, 'intent'=>'action', + 'label'=>$appLabel, 'description'=>'Eventos PHP tipados descobertos automaticamente', + 'keywords'=>['evento','php','automático','eventdispatcher'], + 'actions'=>[], + ]; + } + if (count($nativeByApp[$app]['actions']) >= 60) { continue; } + $nativeByApp[$app]['actions'][] = [ + 'key'=>$native['key'], 'label'=>$native['label'], + 'description'=>$native['description'], + 'eventClass'=>$native['class'], 'phase'=>$native['phase'], + 'webhookCompatible'=>$native['webhook'], + ]; + } + foreach ($nativeByApp as $source) { + if ($source['actions'] !== []) { $sources[] = $source; } + } + + // Optional extension point for native apps that publish an explicit + // recipient/semantic contract; basic event discovery needs no adapter. + $event = new RegisterTriggerCapabilitiesEvent(); + $this->dispatcher->dispatchTyped($event); + foreach ($event->getSources() as $external) { + if (!$this->validateExternal($external)) { continue; } + $sources[] = $external; + } + + $known = []; + $filtered = []; + foreach ($sources as $source) { + $app = $source['appId']; + if ($app !== 'core' && !$this->apps->isEnabledForAnyone($app)) { continue; } + // Avoid spoofing built-in keys and duplicates from different providers. + $validActions = []; + foreach ($source['actions'] as $action) { + $key = $action['key']; + if (isset($known[$key])) { continue; } + $known[$key] = true; + $validActions[] = $action; + } + if ($validActions === []) { continue; } + $source['actions'] = $validActions; + $filtered[] = $source; + } + return $filtered; + } + + private function validateExternal(array $source): bool { + foreach (['id','appId','intent','label','description','actions'] as $key) { + if (!isset($source[$key])) { return false; } + } + if (!is_string($source['id']) || !preg_match('/^[a-z0-9_-]{2,40}$/', $source['id'])) { return false; } + if (!is_string($source['appId']) || !preg_match('/^[a-z0-9_-]{2,40}$/', $source['appId'])) { return false; } + if (!in_array($source['intent'], ['action','visit','schedule','advanced'], true)) { return false; } + if (!is_string($source['label']) || mb_strlen($source['label']) > 80 + || !is_string($source['description']) || mb_strlen($source['description']) > 240) { return false; } + if (!is_array($source['actions']) || count($source['actions']) === 0 || count($source['actions']) > 30) { return false; } + foreach ($source['actions'] as $action) { + if (!is_array($action) || !isset($action['key'],$action['label'],$action['description']) + || !is_string($action['key']) || !preg_match('/^' . preg_quote($source['appId'], '/') . '\.[a-z0-9_.-]{1,40}$/', $action['key']) + || !is_string($action['label']) || mb_strlen($action['label']) > 80 + || !is_string($action['description']) || mb_strlen($action['description']) > 240) { return false; } + } + return true; + } + + /** + * Installed and navigable applications only. Never suggest a server-only + * app as a landing page. The app ID is also used as the browser context. + */ + public function placements(): array { + $options = [ + ['value'=>'any','label'=>'Qualquer página compatível'], + ['value'=>'settings','label'=>'Configurações pessoais'], + ['value'=>'admin_settings','label'=>'Configurações de administração'], + ]; + $seen = ['any'=>true,'settings'=>true,'admin_settings'=>true]; + $navIcons = []; + foreach ($this->navigation->getAll() as $item) { + $href = $item['href'] ?? ''; + if (!is_string($href)) { continue; } + if (!preg_match('~/(?:index\.php/)?apps/([a-z0-9_-]{2,32})(?:/|$)~', $href, $matches)) { + continue; + } + $app = $matches[1]; + $navIcons[$app] = $item['icon'] ?? null; + // Campaigns are not presented over their own administrative editor. + if ($app === 'communications' || isset($seen[$app]) || !$this->apps->isEnabledForAnyone($app)) { continue; } + if ($this->webRoutes->routesFor($app) === []) { continue; } + $seen[$app] = true; + $options[] = ['value'=>$app,'label'=>is_string($item['name'] ?? null) ? $item['name'] : $app, + 'icon'=>$this->safeIcon($navIcons[$app] ?? $this->apps->getAppIcon($app))]; + } + // Discover every enabled app declaring navigation in its installed info.xml, + // even when an app's navigation entry is not loaded for this request. + // Do not promote a server-only app to a UI merely because it is installed. + foreach ($this->apps->getEnabledApps() as $app) { + if ($app === 'communications' || isset($seen[$app]) || !$this->apps->isEnabledForAnyone($app)) { continue; } + $info = $this->apps->getAppInfo($app); + if (empty($info['navigations']['navigation']) || $this->webRoutes->routesFor($app) === []) { continue; } + $seen[$app] = true; + $options[] = [ + 'value'=>$app, + 'label'=>is_string($info['name'] ?? null) ? $info['name'] : $app, + 'icon'=>$this->safeIcon($this->apps->getAppIcon($app)), + ]; + } + usort($options, static function (array $a, array $b): int { + return ($a['value'] === 'any' ? -1 : ($b['value'] === 'any' ? 1 : 0)) + ?: strcasecmp($a['label'], $b['label']); + }); + return $options; + } + + /** + * Configurable applications include event adapters and real navigable GUIs. + * An installed app without an event adapter can still offer page visits. + * Applications that only expose APIs are not page origins. + */ + private function safeIcon(?string $url): ?string { + // Never expose a third-party tracking image from an untrusted navigation entry. + return $url !== null && preg_match('~^/(?!/)[^\\\\?&#<>"\x00-\x1f]+\\.(?:svg|png)$~i', $url) ? $url : null; + } + + public function applications(): array { + $byApp = []; + foreach ($this->sources() as $source) { + if ($source['intent'] !== 'action') { continue; } + $id = $source['appId']; + if (!isset($byApp[$id])) { + $byApp[$id] = [ + 'id'=>$id, 'label'=>$source['label'], + 'icon'=>$this->safeIcon($this->apps->getAppIcon($id)), + 'description'=>$source['description'], + 'keywords'=>$source['keywords'] ?? [], + 'canEvents'=>true, 'canPages'=>false, + 'actions'=>$source['actions'], + ]; + } else { + $byApp[$id]['canEvents'] = true; + $byApp[$id]['actions'] = array_merge($byApp[$id]['actions'], $source['actions']); + } + } + foreach ($this->placements() as $placement) { + $id = $placement['value']; + if (in_array($id,['any','settings','admin_settings'],true)) { continue; } + if (isset($byApp[$id])) { + $byApp[$id]['canPages'] = true; + $byApp[$id]['icon'] = $placement['icon'] ?? $byApp[$id]['icon']; + } else { + $byApp[$id] = [ + 'id'=>$id, 'label'=>$placement['label'], + 'icon'=>$placement['icon'] ?? null, + 'description'=>'Páginas acessíveis na navegação do Nextcloud', + 'keywords'=>['página','rota','acessar','abrir'], + 'canEvents'=>false, 'canPages'=>true, 'actions'=>[], + ]; + } + } + $apps = array_values($byApp); + usort($apps, static fn (array $a,array $b):int => strcasecmp($a['label'],$b['label'])); + return $apps; + } + + /** + * Real adapter readiness versus app installation. Returning a capability + * never enables an unimplemented external delivery adapter. + */ + public function deliveryChannels(): array { + $notifications = $this->apps->isEnabledForAnyone('notifications'); + $activity = $this->apps->isEnabledForAnyone('activity'); + $gateway = $this->gatewayTransport->installed(); + $sources = [ + [ + 'id'=>'notifications', 'label'=>'Notificações do Nextcloud', + 'installed'=>$notifications, 'ready'=>$notifications, + 'availability'=>$notifications ? 'ready' : 'missing_app', + 'status'=>$notifications ? 'Disponível' : 'Não instalado', + 'help'=>$notifications + ? 'Envia um aviso pelo sininho do Nextcloud a cada destinatário autorizado, com link de leitura restrito, sem exigir uma cópia na Caixa de entrada.' + : 'O aplicativo Notifications não está habilitado neste Nextcloud. Um administrador precisa instalar e ativar Notifications em Aplicativos antes de usar o sininho. O serviço notify_push, se existir, não substitui o aplicativo Notifications.', + ], + [ + 'id'=>'activity', 'label'=>'Activity do Nextcloud', + 'installed'=>$activity, 'ready'=>$activity, + 'availability'=>$activity ? 'ready' : 'missing_app', + 'status'=>$activity ? 'Disponível' : 'Não instalado', + 'help'=>$activity + ? 'Registra uma atividade para o destinatário autorizado, com link de detalhe restrito. Não exige cópia na Caixa de entrada.' + : 'O aplicativo Activity não está habilitado neste Nextcloud. É preciso instalar e ativar Activity em Aplicativos antes de registrar avisos nesse canal.', + ], + [ + 'id'=>'email', 'label'=>'E-mail', + 'installed'=>true, 'ready'=>true, 'availability'=>'requires_configuration', + 'status'=>'Verificar SMTP', + 'help'=>'O adaptador de e-mail já está implementado. O envio real exige um servidor de e-mail configurado pelo administrador do Nextcloud e endereços válidos dos destinatários. A disponibilidade de SMTP não é verificada por este catálogo.', + ], + [ + 'id'=>'twofactor_gateway','label'=>'Two-Factor Gateway', + 'installed'=>$gateway,'ready'=>false, + 'availability'=>$gateway ? 'configured_per_provider' : 'missing_app', + 'status'=>$gateway ? 'Ver canais' : 'Não instalado', + 'help'=>$gateway + ? 'Integração nativa com o serviço direto do Two-Factor Gateway, como integração nativa. Configure os provedores e solicite a cada destinatário adesão explícita nas Preferências de avisos.' + : 'Instale e configure o Two-Factor Gateway para utilizar SMS, Signal, Telegram, WhatsApp ou XMPP. O envio depende do provedor e de consentimento do destinatário.', + ], + ]; + foreach (GatewayTransport::LABELS as $id=>$label) { + $ready=$this->gatewayTransport->configured($id); + $sources[]=[ + 'id'=>'gateway:'.$id,'label'=>$label, + 'installed'=>$gateway,'ready'=>$ready, + 'availability'=>!$gateway ? 'missing_app' : ($ready ? 'ready' : 'missing_configuration'), + 'status'=>!$gateway ? 'Não instalado' : ($ready ? 'Disponível' : 'Não configurado'), + 'help'=>!$gateway + ? 'O aplicativo Two-Factor Gateway precisa estar instalado. Este canal permanece inativo e não envia mensagens.' + : ($ready + ? 'Gateway direto configurado. Cada destinatário ainda precisa informar seu próprio identificador e autorizar este canal em Caixa de entrada → Preferências de avisos; publicidade requer autorização adicional.' + : 'Este provedor não possui um gateway direto completo configurado no Two-Factor Gateway. Configure-o em Administração → Segurança. Só depois será possível selecioná-lo.'), + ]; + } + return $sources; + } + + public function supportsPlacement(string $placement): bool { + foreach ($this->placements() as $page) { + if ($page['value'] === $placement) { return true; } + } + return false; + } + + public function supports(string $key): bool { + foreach ($this->sources() as $source) { + foreach ($source['actions'] as $action) { + if ($action['key'] === $key) { return true; } + } + } + return false; + } + + public function legacyTriggers(): array { + $out = []; + foreach ($this->sources() as $source) { + foreach ($source['actions'] as $action) { + $out[] = ['key'=>$action['key'], 'label'=>$action['label']]; + } + } + return $out; + } +} diff --git a/lib/Service/WebRouteCatalog.php b/lib/Service/WebRouteCatalog.php new file mode 100644 index 0000000..ad79dd9 --- /dev/null +++ b/lib/Service/WebRouteCatalog.php @@ -0,0 +1,208 @@ + + */ + public function routesFor(string $app): array { + if (!preg_match('/^[a-z0-9_-]{2,32}$/', $app) + || $app === 'communications' + || !$this->apps->isEnabledForAnyone($app)) { + return []; + } + + // Route files and PHP attributes must both be included. An enabled app + // might not yet have been loaded in this request. + $this->apps->loadApp($app); + $this->router->loadRoutes($app); + $routes = []; + foreach ($this->router->getRouteCollection()->all() as $name => $route) { + if (!str_starts_with($route->getPath(), '/apps/' . $app . '/')) { continue; } + $methods = $route->getMethods(); + if ($methods !== [] && !in_array('GET', $methods, true)) { continue; } + $pattern = $route->getPath(); + if (!self::isWebRoute($pattern, $app, (string)$name) + || !$this->isAuthenticatedHtmlRoute($app, $route)) { continue; } + $tail = substr($pattern, strlen('/apps/' . $app)); + $description = $pattern; + $routes[$pattern] = [ + 'value'=>$pattern, + 'label'=>$tail === '/' ? 'Página principal' : 'Página ' . $tail, + 'description'=>$description, + ]; + if (count($routes) >= 100) { break; } + } + // Do not invent a root route for apps with no declared page. Many apps + // expose only OCS/API endpoints; synthetic routes falsely mark them as UI. + uasort($routes, static function (array $a, array $b): int { + $aVars = substr_count($a['value'], '{'); + $bVars = substr_count($b['value'], '{'); + return ($aVars <=> $bVars) ?: (strlen($a['value']) <=> strlen($b['value'])) ?: strcmp($a['value'],$b['value']); + }); + return array_values($routes); + } + + /** + * This POC injects only authenticated Nextcloud pages. A GET route can + * also return binary data or render a public guest page without our loader. + * Inspect controller metadata when available, rather than treating every + * GET endpoint as a browser navigation target. + */ + private function isAuthenticatedHtmlRoute(string $app, object $route): bool { + $caller = $route->getDefault('caller'); + if (!is_array($caller) || count($caller) < 3 || !is_string($caller[1]) || !is_string($caller[2])) { + return true; // Old route definitions: still subject to path exclusions. + } + try { + $controller = $this->apps->getAppNamespace($app) . '\\Controller\\' . $caller[1]; + $method = new \ReflectionMethod($controller, $caller[2]); + if ($method->getAttributes(PublicPage::class) !== []) { return false; } + $return = $method->getReturnType(); + $types = $return instanceof \ReflectionUnionType ? $return->getTypes() + : ($return instanceof \ReflectionNamedType ? [$return] : []); + // A declared TemplateResponse (possibly with RedirectResponse) is a UI. + foreach ($types as $type) { + if (is_a($type->getName(), TemplateResponse::class, true)) { return true; } + } + foreach ($types as $type) { + // Explicit non-HTML responses are not navigation, even when GET. + if (in_array($type->getName(), [ + 'OCP\\AppFramework\\Http\\JSONResponse', + 'OCP\\AppFramework\\Http\\DataResponse', + 'OCP\\AppFramework\\Http\\DataDownloadResponse', + 'OCP\\AppFramework\\Http\\FileDisplayResponse', + 'OCP\\AppFramework\\Http\\StreamResponse', + ], true)) { return false; } + } + } catch (\ReflectionException|\Error $error) { + // Some custom app controllers cannot be reflected; rely on other guards. + } + return true; + } + + public function isSelectable(string $app, string $pattern): bool { + foreach ($this->routesFor($app) as $route) { + if ($route['value'] === $pattern) { return true; } + } + return false; + } + + /** + * Server-side normalized request path. Never stores or returns real URLs. + * The POC excludes settings, public shares, arbitrary domains and APIs. + */ + public function safePagePath(string $app, string $path): string { + if (!preg_match('/^[a-z0-9_-]{2,32}$/', $app) || strlen($path) > 360 || strlen($path) < 4 + || preg_match('~[?#\x00-\x1f\x7f\\\\]~', $path) + || str_contains($path, '..') || str_contains($path, '//')) { + return ''; + } + if (str_contains($path, '/index.php/apps/')) { + $path = substr($path, strpos($path, '/index.php/apps/') + strlen('/index.php')); + } + $needle = '/apps/' . $app . '/'; + if (!str_starts_with($path, $needle) && $path !== rtrim($needle,'/')) { return ''; } + if (!self::isWebRoute($path, $app, 'browser')) { return ''; } + return $path; + } + + /** + * Per-variable rules are deliberately structured, not arbitrary PCRE. + * This avoids catastrophic backtracking and keeps UI validation predictable. + * + * @param array $rules + * @return array + */ + public function normalizeFilters(string $pattern, array $rules): array { + if ($pattern === '') { + if ($rules !== []) { throw new \InvalidArgumentException('Selecione uma rota antes de filtrar variáveis.'); } + return []; + } + preg_match_all('/\{([A-Za-z][A-Za-z0-9_]*)\}/', $pattern, $matches); + $variables = array_flip($matches[1]); + if (count($rules) > 8) { throw new \InvalidArgumentException('Máximo de oito filtros de rota.'); } + $normalized = []; + foreach ($rules as $name => $rule) { + if (!is_string($name) || !isset($variables[$name]) || !is_array($rule)) { + throw new \InvalidArgumentException('Filtro de rota desconhecido.'); + } + $operator = $rule['operator'] ?? null; + $value = $rule['value'] ?? ''; + if (!is_string($value) || mb_strlen($value) > 96) { + throw new \InvalidArgumentException('Valor de filtro de rota inválido.'); + } + if ($operator === 'digits') { + if ($value !== '') { throw new \InvalidArgumentException('O filtro numérico não aceita valor.'); } + } elseif (in_array($operator, ['equals', 'prefix'], true)) { + // Match a single safe URI segment, never a slash, query, fragment or PCRE. + if ($value === '' || !preg_match('/^[A-Za-z0-9._~-]{1,96}$/D', $value) || str_contains($value, '..')) { + throw new \InvalidArgumentException('Informe um valor válido, sem barras ou expressão regular.'); + } + } else { + throw new \InvalidArgumentException('Operador de filtro de rota inválido.'); + } + $normalized[$name] = ['operator'=>$operator,'value'=>$value]; + } + return $normalized; + } + + /** Match a declared route and optional safe constraints on its variables. */ + public function matches(string $pattern, string $app, string $browserPath, array $filters = []): bool { + $path = $this->safePagePath($app, $browserPath); + if ($path === '') { return false; } + if ($pattern === '') { return $filters === []; } + if (!self::isWebRoute($pattern, $app, 'matcher')) { return false; } + $escaped = preg_quote(rtrim($pattern, '/'), '~'); + preg_match_all('/\{([A-Za-z][A-Za-z0-9_]*)\}/', $pattern, $variables); + foreach ($variables[1] as $variable) { + $escaped = str_replace('\{' . $variable . '\}', '(?P<' . $variable . '>[^/]+)', $escaped); + } + if (preg_match('~^' . $escaped . '/?$~D', $path, $matched) !== 1) { return false; } + foreach ($filters as $name => $rule) { + if (!isset($matched[$name]) || !is_array($rule)) { return false; } + $segment = $matched[$name]; + $operator = $rule['operator'] ?? ''; + $value = $rule['value'] ?? ''; + if ($operator === 'equals' && $segment !== $value) { return false; } + if ($operator === 'prefix' && !str_starts_with($segment, $value)) { return false; } + if ($operator === 'digits' && !ctype_digit($segment)) { return false; } + if (!in_array($operator, ['equals','prefix','digits'], true)) { return false; } + } + return true; + } + + public static function isWebRoute(string $pattern, string $app, string $routeName): bool { + if (strlen($pattern) > 255 || !str_starts_with($pattern, '/apps/'.$app.'/')) { return false; } + if (preg_match('~\.(?:api|ajax|ocs|download|thumbnail|preview|serviceworker)\.~i', $routeName)) { return false; } + if (preg_match('~/(?:api|ocs|ajax|directediting|download|preview|thumbnail|thumb|service-worker|auth|token|health|ws|rss|feed|pdf|crl)(?:/|$)~i', $pattern)) { return false; } + if (preg_match('~/(?:[^/]*\.(?:json|xml|js|css|png|jpe?g|webp|svg|pdf|ics|zip|crl|pem|der|p12|pfx)|[^/]*service-worker[^/]*)$~i', $pattern)) { return false; } + if (preg_match('~\{(?:token|secret|hash|password|signature|csrf|code|path)\}~i', $pattern)) { return false; } + return true; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ceb2bae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6718 @@ +{ + "name": "communications", + "version": "0.29.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "communications", + "version": "0.29.0", + "dependencies": { + "@codemirror/autocomplete": "6.20.4", + "@codemirror/commands": "6.11.1", + "@codemirror/language": "6.13.1", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.15", + "@mdi/js": "7.4.47", + "@nextcloud/vue": "9.14.1", + "@uiw/codemirror-theme-material": "4.25.12", + "vue": "^3.5.18", + "vue-codemirror6": "1.7.0" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@playwright/test": "1.65.0", + "@typescript-eslint/parser": "8.72.2", + "@vitejs/plugin-vue": "^6.0.0", + "eslint": "10.14.0", + "eslint-plugin-vue": "10.12.1", + "globals": "17.14.0", + "sass": "1.108.1", + "vite": "7.3.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.13.tgz", + "integrity": "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@buttercup/fetch": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "optional": true, + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@ckpack/vue-color": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@ckpack/vue-color/-/vue-color-1.6.0.tgz", + "integrity": "sha512-b9kFTKhYbNArfgP1lmnaVm0VNsWdZjqIbyHUYry7mZ+E7JeTQclbjq1+2xWn0SE3wzqRYlXmAVjECPOgteWmMQ==", + "dependencies": { + "@ctrl/tinycolor": "^3.6.0", + "material-colors": "^1.2.6" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz", + "integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==" + }, + "node_modules/@codemirror/view": { + "version": "6.43.15", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz", + "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "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.3", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.3.tgz", + "integrity": "sha512-R76UKbvsqI68DJJVTVyqFKqU98CN4+B+acxNfEA2IFarSzP0SjtKf/3+fFiXzTyv5Fj3PxLkGHe2wnVWWMxobQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@file-type/xml": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@file-type/xml/-/xml-0.4.4.tgz", + "integrity": "sha512-NhCyXoHlVZ8TqM476hyzwGJ24+D5IPSaZhmrPj7qXnEVb3q6jrFzA3mM9TBpknKSI9EuQeGTKRg2DXGUwvBBoQ==", + "dependencies": { + "sax": "^1.4.1", + "strtok3": "^10.3.4" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "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==", + "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==", + "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==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" + }, + "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==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true + }, + "node_modules/@lezer/common": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz", + "integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz", + "integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==" + }, + "node_modules/@mdi/js": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", + "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==" + }, + "node_modules/@nextcloud/auth": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.6.0.tgz", + "integrity": "sha512-VkT87+9UqpPi7O36bVEE4/MxWF8d90VQcuMlvKltsZyLSLkEGrPXgowtD75Y54k60/8SR6mXbeqBwapi8dDUbA==", + "dependencies": { + "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/router": "^3.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/axios": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-2.6.0.tgz", + "integrity": "sha512-ehcIgyora8DAJ+STG6iFI4e+ufPVFrIA6o0FgMKeKdfyaxRJ9UM7L+n7V+rc/qv8sDiWC/hWIKwFtLw2W5yE4Q==", + "dependencies": { + "@nextcloud/auth": "^2.6.0", + "axios": "^1.15.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/browser-storage": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.5.0.tgz", + "integrity": "sha512-usYr4GlJQlK3hgZURvklqWb9ivi7sgsSuFqXrs7s4hl1LTS4enzPrnkQumm6nRsQruf0ITS+OBsK+oELEbvYPA==", + "engines": { + "node": "^24 || ^22 || ^20" + } + }, + "node_modules/@nextcloud/capabilities": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@nextcloud/capabilities/-/capabilities-1.2.1.tgz", + "integrity": "sha512-snZ0/910zzwN6PDsIlx2Uvktr1S5x0ClhDUnfPlCj7ntNvECzuVHNY5wzby22LIkc+9ZjaDKtCwuCt2ye+9p/Q==", + "dependencies": { + "@nextcloud/initial-state": "^3.0.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/event-bus": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-3.3.3.tgz", + "integrity": "sha512-zIfvKmUGkXpVzRKoXrcO9hkoiKDm65fqNxy/XIbIxrQhZByPq3gDkjBpnu3V5Gs8JdYwa73R8DjzV9oH8HYhIg==", + "dependencies": { + "@types/semver": "^7.7.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^20 || ^22 || ^24" + } + }, + "node_modules/@nextcloud/files": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.1.1.tgz", + "integrity": "sha512-3fvaBNObXFjVgFm0FvPXr+rtYh2CUGAc0+98dhIP0AC7Q+UCVYIwyB3tsSBGP29HnqGWqGRY7nZaj7gI7NzbvQ==", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.6.0", + "@nextcloud/axios": "^2.6.0", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.1.0", + "@nextcloud/router": "^3.2.0", + "@nextcloud/sharing": "^0.4.0", + "axios-retry": "^4.5.0", + "is-svg": "^6.1.0", + "p-queue": "^9.3.3", + "typescript-event-target": "^1.1.2", + "webdav": "^5.11.0" + }, + "engines": { + "node": "^24.0.0 || >=26.0.0" + } + }, + "node_modules/@nextcloud/initial-state": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-3.0.0.tgz", + "integrity": "sha512-cV+HBdkQJGm8FxkBI5rFT/FbMNWNBvpbj6OPrg4Ae4YOOsQ15CL8InPOAw1t4XkOkQK2NEdUGQLVUz/19wXbdQ==", + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/l10n": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-3.4.1.tgz", + "integrity": "sha512-aTFinTcKiK2gEXwLgutXekpZZ8/v/4QiC8C3QCLH5m0o+WtxsBC+fqV142ebC/rfDnzCLhY4ZtswSu8bFbZocg==", + "dependencies": { + "@nextcloud/router": "^3.0.1", + "@nextcloud/typings": "^1.9.1", + "@types/escape-html": "^1.0.4", + "dompurify": "^3.2.6", + "escape-html": "^1.0.3" + }, + "engines": { + "node": "^20 || ^22 || ^24" + } + }, + "node_modules/@nextcloud/logger": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@nextcloud/logger/-/logger-3.0.3.tgz", + "integrity": "sha512-TcbVRL4/O5ffI1RXFmQAFD3gwwT15AAdr1770x+RNqVvfBdoGVyhzOwCIyA5Vfc3fA1iJXFa+rE6buJZSoqlcw==", + "dependencies": { + "@nextcloud/auth": "^2.5.3" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/paths": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz", + "integrity": "sha512-vtFYA/kthaUDzu6KejTOL1OwnOy7/yynq5zdB/UBpYacAWjUX5Ddh4OMWx3rEavkBJ9/QGhrFryNJLjNfe8OQA==", + "optional": true, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/router": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.2.0.tgz", + "integrity": "sha512-an4gJ7mlDZGtYq8zTxp4Sg+iLlKKGvQ9ZH3Dp6ga/IlDNAP7VeMVGphsY798ORjncAp9+sEf370L5zpsnuxgng==", + "dependencies": { + "@nextcloud/typings": "^1.10.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/sharing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.4.0.tgz", + "integrity": "sha512-1hUNyc7uJdBpnimOnEshJjEtAPAjzDYVl6qmWqF5ZxoN9wOvbExw0QjX3xFIbHbX2dmvbRNLBj0RzLzipmZyeg==", + "dependencies": { + "@nextcloud/initial-state": "^3.0.0", + "is-svg": "^6.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@nextcloud/files": "^3.12.2 || ^4.0.0" + } + }, + "node_modules/@nextcloud/typings": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.10.0.tgz", + "integrity": "sha512-SMC42rDjOH3SspPTLMZRv76ZliHpj2JJkF8pGLP8l1QrVTZxE47Qz5qeKmbj2VL+dRv2e/NgixlAFmzVnxkhqg==", + "dependencies": { + "@types/jquery": "3.5.16" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/vue": { + "version": "9.14.1", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.14.1.tgz", + "integrity": "sha512-qLCYNx0fFy2ZI+2e6nmNtzNfHsU/utwCM+u67kqLUi/Uk11RrFxEXb2DxOJwodbF2K/EArtmuk3l9FJqYJ6v/g==", + "dependencies": { + "@ckpack/vue-color": "^1.6.0", + "@floating-ui/dom": "^1.8.0", + "@nextcloud/auth": "^2.6.0", + "@nextcloud/axios": "^2.6.0", + "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/initial-state": "^3.0.0", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/router": "^3.2.0", + "@nextcloud/sharing": "^0.4.0", + "@nextcloud/vue-select": "^4.1.0", + "@vuepic/vue-datepicker": "^11.0.3", + "@vueuse/components": "^15.0.0", + "@vueuse/core": "^15.0.0", + "blurhash": "^2.0.5", + "clone": "^2.1.2", + "debounce": "^3.0.0", + "dompurify": "^3.4.17", + "emoji-mart-vue-fast": "^15.0.5", + "escape-html": "^1.0.3", + "floating-vue": "^5.2.2", + "focus-trap": "^8.2.3", + "linkifyjs": "^4.4.0", + "mdast-util-to-string": "^4.0.0", + "p-queue": "^10.0.0", + "rehype-external-links": "^3.0.0", + "rehype-highlight": "^7.0.2", + "rehype-react": "^8.0.0", + "remark-breaks": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-unlink-protocols": "^1.0.0", + "splitpanes": "^4.0.4", + "striptags": "^3.2.0", + "tabbable": "^6.5.1", + "tributejs": "^5.1.3", + "ts-md5": "^2.0.1", + "unified": "^11.0.5", + "unist-builder": "^4.0.0", + "unist-util-visit-parents": "^6.1.0", + "vue": "^3.5.44", + "vue-router": "^5.4.0" + }, + "engines": { + "node": "^20.11.0 || ^22 || ^24" + }, + "peerDependencies": { + "@nextcloud/dialogs": "^7.4.0 || ^8.0.0", + "@nextcloud/files": "^4.1.1" + }, + "peerDependenciesMeta": { + "@nextcloud/dialogs": { + "optional": true + }, + "@nextcloud/files": { + "optional": true + } + } + }, + "node_modules/@nextcloud/vue-select": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/vue-select/-/vue-select-4.1.0.tgz", + "integrity": "sha512-jQIu4XuUAuJr6qL/IKa63h3Vv/4OrP9latl2E6kqtffwLBV+qMSU4Gm+vsOfyqBbBSY4i3eeMfdyJi14O1Yqbg==", + "engines": { + "node": "^22 || ^24" + }, + "peerDependencies": { + "vue": "^3" + } + }, + "node_modules/@nextcloud/vue/node_modules/p-queue": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-10.0.0.tgz", + "integrity": "sha512-P2wrCri3bhGLPo3kX7qYVXbo/5GvaVATU0DpO24CbeT53Y4z35t8JUWku04ERpXHUYB5rPMi3TkWE3fmJgx5rA==", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "optional": true + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.26.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.68.0.tgz", + "integrity": "sha512-Ta2n0ntLfzP0LqwwcXOIh2z0mnHjlxMS+h4Y9lo+w33Quyf9KFKEi9QZ2IKqeEBKr0qxNpXc/DQjz5KVIs8AEA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.68.0.tgz", + "integrity": "sha512-iFDtS0oMaBvKbtrRKXmRcBSsI0UaWL2bWpd62iHzb0A8RSPrVz47mUGE0CfrrD0c/7/7dUhNtoN3cc6aEUn1Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.68.0.tgz", + "integrity": "sha512-OnYETr+cZYSbesBD1tCvUjrBUTkjSaOcewRUoIHqD0XI3yn5i9WpdFaE3LZwrMlq0yeJNU0N5sxfHK/fNVMqlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.68.0.tgz", + "integrity": "sha512-2qavWZtFeon/6oMkjsBF3OH7ENKgRTW4xRTwmz9VJeNUYhmPitpR9Jd4sAAc2OZJryHs2neoovMTCYnNvhvANQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.68.0.tgz", + "integrity": "sha512-QpgJO+GwNQDPt6uYcFSjZTFVyHNIeumN2am7RISDR9Nf3e/h8sur9cIfplPGihZPUn+sTw9j3JErZIUkHg8+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.68.0.tgz", + "integrity": "sha512-TERqEn6P9bp4lsTyoFjT3nLKV6G++76HhGb4k6dRGcs+jvhVcTGaS2zkeRdLLRjwKxpBtdCZe11kUQ2hNGgFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.68.0.tgz", + "integrity": "sha512-RBjDrqb7h5l6Xgdgz5eKYvbpnUkii/z5ejWuZmHJsXORi0X0tTtFzEEgJj2DX995kW18c6T9LGFn/deEAMXDcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.68.0.tgz", + "integrity": "sha512-HtLgXBqjF1Ampt9eJjzSmoGDvNb4LP8Y2aPXQZeQ5qhyExXLmoXJB9Jfm/nPi/8fJ3UFn4sZ7pQKyr8V+oTN8g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.68.0.tgz", + "integrity": "sha512-q51xJyqjaqCNtDjYD7xNVwsPa2TcVfAIBeP6E20wicKZ6XYp8Taz9SUPYhIENojAQIAoV7cO3mUdMUgWYEAuhQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.68.0.tgz", + "integrity": "sha512-wejnVabl92oFC/o4sr/EEJuJZMHtMq4C+LBIPphN4EUiVHe9qvZNw+biqhVHpX2ggs1+Z3ugXGwJxT+qhyaaYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.68.0.tgz", + "integrity": "sha512-tyu+hrsCWHHqV6+2tligV/recJH+HVvNV2Sp4nS1C+MKQPkNXRovKIzyHy0V0xXjm6igCLgsT2vTky5CvWP37g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.68.0.tgz", + "integrity": "sha512-5gjJNglKDfgs3OmnGi/RVZQPhEfRoCIHxwSAP9ngmdnxreu0Vl9lI4gxmFBXOhKdftpS82xRdc/7Dp5/A1F0qg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.68.0.tgz", + "integrity": "sha512-2IBxTUds33rFYwOIEyf77wfW8WQThwiQQ1ofXSraZQG7PiIq7BnQBf/ge0qEI/Ny/2cSc7SKkGO/wUDqmYXqTg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.68.0.tgz", + "integrity": "sha512-mE/Pj2PPTZIVHkNNuHq+eYoU3td8LCm73zjrXoBWkNlnuULJS2l/fxwgGPpN4WUCNZgKZ1IXwrnnETPyOcWPmg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.68.0.tgz", + "integrity": "sha512-dYuykUkAiTv+RQdP3Ib47XcFiImkBWiRpTGSQWAMfZPQyoQYGe8Je4IUeZYNUQWIojmRCUnt5xsH4BbUb2uqSw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.68.0.tgz", + "integrity": "sha512-OnPDDFXLqJewY56T6ow8sA4wuzbqlliD7QJgkrx2KX+pWOpjI3usYXgB0pwK3nsgTF2odCw9IuJs0Uis/jy2qA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.68.0.tgz", + "integrity": "sha512-CqoQf06cK0brrRfDqH7Mt8G4MdteFGbjBdC61zEMaSVna6xo6QMUixa9bAkAD4a92g3ElPbQAkLMUzdf+Lk8dg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.68.0.tgz", + "integrity": "sha512-LPHNmE8GwiyELGLUTkqj4zsP1YEHzGdtwnKxAcdP/swsNxXsfxPtpe5nrD/KGxr136xPB+eWv2r/SGj9md0pnQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.68.0.tgz", + "integrity": "sha512-GDOmvgephpRCNyNGRQOZh0KD2gTyRVM1Bb1iOo759xeCUpCAoRCmJioOOSu2MwyWfNOocGd0V5I9vYLfteC6/A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.68.0.tgz", + "integrity": "sha512-HADRxkvl5eg3hbemCmv81UwTfdQy/CiSJDOabLE+p5tpm3OTjuRtzpLXy5zPfGY9FQD4Qv7qHptXmG/YjmNmyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.68.0.tgz", + "integrity": "sha512-iERrpqu1VEfyX736HLKiXRbwGDkCV/iybARY/mn14KcI6oKhJGBx1e+bU+g+umTKghtiGSPaHkwwpII4/N3RjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.68.0.tgz", + "integrity": "sha512-hBn57IqAbpXpyktckonwePTaFOQS6THph8ZnpRO3eZHY4LncuZGjSsseigBnKuSMKKpLnJJ7PemDvXEzniXC+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.68.0.tgz", + "integrity": "sha512-pJWb7mIw9Z25Hl3Q6nHFjl7AnczfOpm2gbOFYguSRv6TiH7DzsoAk/thymb/URSQu9OUEK7uaDMC5Xfm+7TAww==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.68.0.tgz", + "integrity": "sha512-pnrhmoyF2otH2Mezol14/ELUFGuRddOCz6+I+J0pq2Itnp4UxkjABQcXawCNMaKNsCRiuL90BtNzbEd0fHjpIg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.68.0.tgz", + "integrity": "sha512-1dJ3U+ktdtaIdPaOZKwIX57+bKQk4iAjs5M1wQWLKRB6Wglp98qf8LUeuN4u6bGlIYouZKaySZJ5Qd4OalUMnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.68.0.tgz", + "integrity": "sha512-e/NRJFl48wL5DFNSzQrvm4kaB5dzNNmqWjIJ6And2Sjuw4klO3w3TXMvCAEF1tHTrQQgpOD4QVkBE/92/yBGrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "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/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/escape-html": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz", + "integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/jquery": { + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", + "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", + "dependencies": { + "@types/sizzle": "*" + } + }, + "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/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==" + }, + "node_modules/@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.2.tgz", + "integrity": "sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.2", + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/typescript-estree": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2", + "debug": "^4.4.3" + }, + "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 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.2.tgz", + "integrity": "sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.2", + "@typescript-eslint/types": "^8.72.2", + "debug": "^4.4.3" + }, + "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.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz", + "integrity": "sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2" + }, + "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.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz", + "integrity": "sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.2.tgz", + "integrity": "sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==", + "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.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz", + "integrity": "sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.72.2", + "@typescript-eslint/tsconfig-utils": "8.72.2", + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.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.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz", + "integrity": "sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.72.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@uiw/codemirror-theme-material": { + "version": "4.25.12", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-material/-/codemirror-theme-material-4.25.12.tgz", + "integrity": "sha512-Meh4ImDVV2QzOs4QsIuMK1G/O7dPUL2HzyYU4FfVoo0danFhEPj2kJhvxLu7qabFwqfuQHija208gP302RuLYg==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@lezer/highlight": "^1.0.0", + "@uiw/codemirror-themes": "4.25.12" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, + "node_modules/@uiw/codemirror-themes": { + "version": "4.25.12", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.12.tgz", + "integrity": "sha512-3bLX6C+gstr82SmY2FAj2TfHXnl2SnC+whRZJmRsv82wMhG8RZlZVy+o0rLuGRIPD9acuY0gpouHVRNUnzBj3g==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/language": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.26.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.26.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.45.tgz", + "integrity": "sha512-XGHJrM6ufZARAc/w+tyOswStw9tdKiqCKBScI4gGaNxD+aaGu/HQWgKJ+JItjRLPqenQ7iE7G8AJC7T5ew1p+g==", + "dependencies": { + "@babel/parser": "^7.29.12", + "@vue/shared": "3.5.45", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.45.tgz", + "integrity": "sha512-LbP2d19GSOYGPmvMBBCVJVlv7TKtjM1Cfeyu3cYia720pknZP+1cLX7H5MVatvruT6MuJhiPuNbQBW/MDn/EwA==", + "dependencies": { + "@vue/compiler-core": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.45.tgz", + "integrity": "sha512-MKFjb2UX0M/l/jhfzqMLrI8VM4pfvFKG3pqFZkSZFHRLtNld/O4RtUPZFWSwhro/14qP88UQEkWzHxAZAQ0tsw==", + "dependencies": { + "@babel/parser": "^7.29.12", + "@vue/compiler-core": "3.5.45", + "@vue/compiler-dom": "3.5.45", + "@vue/compiler-ssr": "3.5.45", + "@vue/shared": "3.5.45", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.32", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.45.tgz", + "integrity": "sha512-RF5sbZOfYz5AK00tOJUn2PwFXR2fS1u2HVzI8D9VF9rGAFxuGBqg2bB1kJ3ZHMprqSltXnT9MZE1IsqjSM8oew==", + "dependencies": { + "@vue/compiler-dom": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.45.tgz", + "integrity": "sha512-WqkyubNWpI4OYBOipSrjIssMdJO8K1i9Zts/Wm8Er10/0msOEwCyWWWvZnAI7WW3kQsTong6DJp8Cbb+vd4cPA==", + "dependencies": { + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.45.tgz", + "integrity": "sha512-bzIO3+FABjpj85uIKSc+Qnj36/sxDNI3tcH9i6w6xKA1k6e0ejv0G0jMCfeJBvK3yGL5XbJ3DMZj+ClqyCHFYw==", + "dependencies": { + "@vue/reactivity": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.45.tgz", + "integrity": "sha512-ZvRTFMDgfqTDYWvJfoVpQTJx1RN5M6l7Pyd6wfdY2NaXzbPQVh9ezeQYFbNPP6bIcRVn2SqSSR25N/jSW69BFw==", + "dependencies": { + "@vue/reactivity": "3.5.45", + "@vue/runtime-core": "3.5.45", + "@vue/shared": "3.5.45", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.45.tgz", + "integrity": "sha512-1fNXUHhhsWf4EVL4XixbGBU5mKdiIXJAB82feHRTyPPo6ZUEYWIJuEeUl3mVV6QDBp50HJOX4obLhxYXlrjrzA==", + "dependencies": { + "@vue/compiler-ssr": "3.5.45", + "@vue/runtime-dom": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.45.tgz", + "integrity": "sha512-ue9fgHmVNPlP7iW3+cxoBX/Cnlw53dHlVSw0JKGY6z1lObg8pHoj928M2nzG4/yVsMZ3NaRZ2s2z61uC16NDwQ==" + }, + "node_modules/@vuepic/vue-datepicker": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-11.0.3.tgz", + "integrity": "sha512-sb2adwqwK2PizLQOpxCYps2SwhVT6/ic2HMIOqHJXuYa6iAJZWGL5YVlS7O4aW+sk6ZyxlDURLO7kDZPL4HB/w==", + "dependencies": { + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "vue": ">=3.3.0" + } + }, + "node_modules/@vueuse/components": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/components/-/components-15.0.0.tgz", + "integrity": "sha512-gX6FE9oVef+HUKhfUx9OTxJ+XGFjDEMi5p+Y7Pr2gYgxumSnfsfA/J0BgoI0GTK6x/x0Iu8AYbl1KM2w4g/T2g==", + "dependencies": { + "@vueuse/core": "15.0.0", + "@vueuse/shared": "15.0.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/core": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-15.0.0.tgz", + "integrity": "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "15.0.0", + "@vueuse/shared": "15.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-15.0.0.tgz", + "integrity": "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-15.0.0.tgz", + "integrity": "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "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/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": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.26.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.10.0.tgz", + "integrity": "sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw==", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "ast-kit": "^3.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/helper-string-parser": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", + "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/parser": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", + "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", + "dependencies": { + "@babel/types": "^8.1.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/types": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", + "dependencies": { + "@babel/helper-string-parser": "^8.0.8", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/ast-kit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.1.tgz", + "integrity": "sha512-fCGiQvmHghhf858e/SVGh2aUo/FsbtB5qB8bifo6ygHK5OMcGna56FUvS0IrYvo+unYwIi1nCklEh/KUaOKVmg==", + "dependencies": { + "@babel/parser": "^8.1.0", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "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.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz", + "integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "optional": true, + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "optional": true + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "optional": true + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", + "optional": true + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "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/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.26.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "peer": true, + "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" + } + }, + "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/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/confbox": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.2.tgz", + "integrity": "sha512-ZUAKhKlkCOrIGKc8cj7zek9s9z1mveHif/25nUshI2BzCRzF7KH7KHa+Lnh2ubq+e1tIlaFuP763mzW4qVoehg==" + }, + "node_modules/core-js": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", + "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", + "hasInstallScript": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==" + }, + "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/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "optional": true, + "engines": { + "node": "*" + } + }, + "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==" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debounce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "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/emoji-mart-vue-fast": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-15.0.5.tgz", + "integrity": "sha512-wnxLor8ggpqshoOPwIc33MdOC3A1XFeDLgUwYLPtNPL8VeAtXJAVrnFq1CN5PeCYAFoLo4IufHQZ9CfHD4IZiw==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "core-js": "^3.23.5" + }, + "peerDependencies": { + "vue": ">2.0.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=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-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", + "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "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", + "minimatch": "^10.2.6", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.12.1.tgz", + "integrity": "sha512-Z5hmchVWq4EEOCn131J2p/uS4hjkrC+goPV5c/YApTY68xTqV3K5IYqD0mYGupDvZ6iYoTSuhSZUoWN+gZIqZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.7", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/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/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "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/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "optional": true + }, + "node_modules/exsolve": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.2.0.tgz", + "integrity": "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg==" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "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/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.12.0.tgz", + "integrity": "sha512-/pXxRBtwxY9w8z7YbgsvIPPcPvFDsTbIkWwTQJhzNJPokj+TbN6ftmcv4j/pEQDvOCNnKGDzuk5Bo2oogIOiFg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "dependencies": { + "@nodable/entities": "^3.1.0", + "fast-xml-builder": "^1.3.1", + "is-unsafe": "^2.0.3", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.5.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "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": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true + }, + "node_modules/floating-vue": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-5.4.0.tgz", + "integrity": "sha512-hb5y5N8sXs9Dy4iZfWBKLzf3FkUkqhbkQREhRulAEvIihMlSGxc/7pXGrIDetoqXm/RRHNVjvN6lql9IhMM+tQ==", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.0 || ^4.0.0 || ^5.0.0-0", + "vue": "^3.2.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/focus-trap": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.4.tgz", + "integrity": "sha512-oy6dj7WZYgMbbpoOA29K5W++473F1zEzdI1oJgPb6x7yWT36YaWedUe6i6YPWEt5quSh0pRMohVyHLWXUxtdxg==", + "dependencies": { + "tabbable": "^6.5.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "optional": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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/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": "17.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz", + "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==", + "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/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/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.7.tgz", + "integrity": "sha512-F7DNV2NMcTxA4GqPVHnXzX+mpoPHAIY9rJAVH3CZ1FdBZOcBUEjUuvMkMpHVs/sicEtznh9tMGj5WUR9S4E2rg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^2.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.3.tgz", + "integrity": "sha512-5yrlWOvGFs6CqHriUpSsNrjzT/Tdo+IOQudbznxOn8K5qblp18MJyzOg8IgDOKQ8MYgQcJA7SusuCjSpDr8mSA==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true + }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", + "optional": true + }, + "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/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/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true + }, + "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/inline-style-parser": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.9.tgz", + "integrity": "sha512-IttN8BqKLxzUrnTqHI+/hwAJQItBAUjdoHERCDv+oOuX48VP6bzW8+QjOXSfoevzq3YrYAwX1uLHp8TqO9yIeg==" + }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "optional": true + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-svg": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-6.1.0.tgz", + "integrity": "sha512-i7YPdvYuSCYcaLQrKwt8cvKTlwHcdA6Hp8N9SO3Q5jIzo8x6kH3N47W0BvPP7NdxVBmIHx7X9DK36czYYW7lHg==", + "dependencies": { + "@file-type/xml": "^0.4.3" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz", + "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true + }, + "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/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/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "optional": true + }, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz", + "integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==" + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.26.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, + "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/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "optional": true, + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdast-squeeze-paragraphs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-6.0.0.tgz", + "integrity": "sha512-6NDbJPTg0M0Ye+TlYwX1KJ1LFbp515P2immRJyJQhc9Na9cetHzSoHNYIQcXpANEAP1sm9yd/CTZU2uHqR5A+w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.4.tgz", + "integrity": "sha512-z4B5c6jrnJ1ltOQemhWYPBSAo6y42Z8Gt2yEgfgNTMcLSU59B8bfj8Z8AnAlJrBGLnqkdAUZ7jdDEqQw3KuipQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.1.tgz", + "integrity": "sha512-oXp1GIa+E9IwIs3a106+tr3Bz2MlTTBvlXjOZMoIat11kk7vjqgmBnksB9WHmWv+gZh7k3IUgpAHlwHB7DH4GQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.3.tgz", + "integrity": "sha512-dImdB6VZRMRac1SwAW6o1c0ESk/7pFFAMCMuKM+9v7LpTSthMM5nzXpqKw4Mjo66aXVKhNkyrP4bMB9MurPyWg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "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": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "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/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", + "optional": true + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/nostics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.3.0.tgz", + "integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "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/p-queue": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.5.tgz", + "integrity": "sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==", + "optional": true, + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", + "optional": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "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-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "engines": { + "node": ">=14.0.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-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", + "optional": true + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==" + }, + "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.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", + "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.3", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.2.0.tgz", + "integrity": "sha512-Khh/tERVBn0I1499dvBSHY0pWJloMJWUxDsOBM0CCDPAcVZ10Jl2L1qy1f19uEX22spa8Mv7DCdEQJc0cGwBhQ==", + "dev": true, + "dependencies": { + "util-deprecate": "^1.0.2" + }, + "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, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "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/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ] + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "optional": true + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "engines": { + "node": ">= 20.26.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rehype-external-links": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-is-element": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-react": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/rehype-react/-/rehype-react-8.0.0.tgz", + "integrity": "sha512-vzo0YxYbB2HE+36+9HWXVdxNoNDubx63r5LBzpxBGVWM8s9mdnMdbmuJBAX6TTyuGdZjZix6qU3GcSuKCIWivw==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-unlink-protocols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-unlink-protocols/-/remark-unlink-protocols-1.0.0.tgz", + "integrity": "sha512-5j/F28jhFmxeyz8nuJYYIWdR4nNpKWZ8A+tVwnK/0pq7Rjue33CINEYSckSq2PZvedhKUwbn08qyiuGoPLBung==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-squeeze-paragraphs": "^6.0.0", + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "optional": true + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "optional": true, + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.26.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/rollup": { + "version": "4.68.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.68.0.tgz", + "integrity": "sha512-BIHWnc1Qjdy+WLfOdcSd67sxO4iW6pTnhdZosg+7ywSaiMWxxzKldkkNpUJqeXYAiJzyB14P2cIjqC6tlb6xzw==", + "devOptional": true, + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.68.0", + "@rollup/rollup-android-arm64": "4.68.0", + "@rollup/rollup-android-x64": "4.68.0", + "@rollup/rollup-darwin-arm64": "4.68.0", + "@rollup/rollup-darwin-x64": "4.68.0", + "@rollup/rollup-freebsd-arm64": "4.68.0", + "@rollup/rollup-freebsd-x64": "4.68.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.68.0", + "@rollup/rollup-linux-arm-musleabihf": "4.68.0", + "@rollup/rollup-linux-arm64-gnu": "4.68.0", + "@rollup/rollup-linux-arm64-musl": "4.68.0", + "@rollup/rollup-linux-loong64-gnu": "4.68.0", + "@rollup/rollup-linux-loong64-musl": "4.68.0", + "@rollup/rollup-linux-ppc64-gnu": "4.68.0", + "@rollup/rollup-linux-ppc64-musl": "4.68.0", + "@rollup/rollup-linux-riscv64-gnu": "4.68.0", + "@rollup/rollup-linux-riscv64-musl": "4.68.0", + "@rollup/rollup-linux-s390x-gnu": "4.68.0", + "@rollup/rollup-linux-x64-gnu": "4.68.0", + "@rollup/rollup-linux-x64-musl": "4.68.0", + "@rollup/rollup-openbsd-x64": "4.68.0", + "@rollup/rollup-openharmony-arm64": "4.68.0", + "@rollup/rollup-win32-arm64-msvc": "4.68.0", + "@rollup/rollup-win32-ia32-msvc": "4.68.0", + "@rollup/rollup-win32-x64-gnu": "4.68.0", + "@rollup/rollup-win32-x64-msvc": "4.68.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.108.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.108.1.tgz", + "integrity": "sha512-sIYgSvOPeD9wABwDS/ofNjHASSck+7h3jLkk7wG1NPFL3XeS6vOQjBlAFSmRV09kZr1yt/Tuayt6kKtBbLjHNQ==", + "dev": true, + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.26.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==" + }, + "node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/splitpanes": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/splitpanes/-/splitpanes-4.1.2.tgz", + "integrity": "sha512-frNchoCv7w0nMQEuaDGgMGMU6jv3NhN6bBGQVfCNgwJdVcYaRmi1dwYDjp7SifAoUFdiQjBRB254LJNidzo15Q==", + "funding": { + "url": "https://github.com/sponsors/antoniandre" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==" + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/strtok3": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.7.0.tgz", + "integrity": "sha512-iAqrCpp7UXD2FZiHWhLXqdJ6CmNeVzRkkHAyyhhKMVtbVm2KtPX3D/bJNonvtcyLTHS4rwqP2pz9fsjK1K4z0Q==", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==" + }, + "node_modules/style-to-object": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-2.0.2.tgz", + "integrity": "sha512-QmbXPUylqU80HAgTOJeEOdbA40W9KtxmjMhQy+tq+b8F2aoh3aQLuy82hh2jm2qsC9hR9eI0+eQidTMydKcqRA==", + "dependencies": { + "inline-style-parser": "0.2.9" + } + }, + "node_modules/tabbable": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.1.tgz", + "integrity": "sha512-dMCuwDUY6Noz3zVKLefBC4wq+aJV20yc7vWmM7uTNGwtQSFF5kX7OUIUsxuVajz/bdt5Xqdah3/0NLjth+cM5A==" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tributejs": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/tributejs/-/tributejs-5.1.3.tgz", + "integrity": "sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-md5": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-2.0.1.tgz", + "integrity": "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w==", + "engines": { + "node": ">=18" + } + }, + "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": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-event-target": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.2.tgz", + "integrity": "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-builder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-4.0.0.tgz", + "integrity": "sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.2.0.tgz", + "integrity": "sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.1.0.tgz", + "integrity": "sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unplugin": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.7.0.tgz", + "integrity": "sha512-GrM2uq3DQo3hvLU2FjeYhooJ8BY9N6j1Fbzgx7Pta3VVJevsD3VP4dPjZkZOS0mLvN8w9zzr9OlP+31bSMyuLw==", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@rolldown/pluginutils": "^1.0.1", + "picomatch": "^4.0.7", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.26.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.26.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "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/url-join": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.2.0.tgz", + "integrity": "sha512-/tMVQdp/2eE90fGb6npbd2nLHH8jamzLLeZhLEKTz4Rc38wO5AeEuIhoWroWWfQJS50ZzL4Xod9JER/pJWTTEQ==", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "optional": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.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==", + "dev": true + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.4.tgz", + "integrity": "sha512-66ycRswIkNmHH9PHku7ADtv5RVwgIrOcuv3J8Svz6h4qgGRrKVa+9/neuKyJZUXyOpYwLTvS+whZ4R2urHHgQg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.8.tgz", + "integrity": "sha512-3tTi78MAKhVk9h+YZVXMQ8POmneJuE97QUPAWtJghHzhH7BAyiqQOC2KZh1DWOsu/USB2cRXhznPByaabvYXfw==", + "devOptional": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.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/vue": { + "version": "3.5.45", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.45.tgz", + "integrity": "sha512-3cvsib8m9c0vB24+MDKsgcW4+Da4xaBl9Z/5Tbm2pmSIsRPxcil4cdXjXlVsmJ2g64XJo5h+IRd7rsdGT9pZaw==", + "dependencies": { + "@vue/compiler-dom": "3.5.45", + "@vue/compiler-sfc": "3.5.45", + "@vue/runtime-dom": "3.5.45", + "@vue/server-renderer": "3.5.45", + "@vue/shared": "3.5.45" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-codemirror6": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vue-codemirror6/-/vue-codemirror6-1.7.0.tgz", + "integrity": "sha512-OPXoJN3S8NCq6xld4r6o5IuzVwgZJ46b7kU6bEq7uwTQkLW+thC8oXp2ZyWy8JfSVRoURExpaRzqK4cV5+IB6g==", + "dependencies": { + "vue-demi": "latest" + }, + "peerDependencies": { + "@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", + "codemirror": "^6.0.0", + "style-mod": "^4.0.0", + "vue": "^2.7.14 || ^3.3.4" + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.2.tgz", + "integrity": "sha512-WMpzulb50kb6snghzoGzS++62wcOszAk/piYcnvzdStM8Wni/BmogYltpoOFv2eK9hDbXxvwFQgnwZGpAoUfpA==", + "dev": true, + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-router": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.4.0.tgz", + "integrity": "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw==", + "dependencies": { + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.10.0", + "chokidar": "^5.0.0", + "confbox": "^0.3.1", + "local-pkg": "^1.2.1", + "magic-string": "^1.4.3", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.3.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.7", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.4.0", + "unplugin-utils": "^0.3.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.11.0.tgz", + "integrity": "sha512-DbUS8rHvWse55EiHM0q5q1z3eVRCLWW7Va7mUqA2/JJdQHIew4Op2HTglHkBJDOAVrQeQf0I+REUJkIrYqtL+w==", + "optional": true, + "dependencies": { + "@buttercup/fetch": "^0.2.1", + "@nodable/entities": "^3.0.0", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "entities": "^6.0.1", + "fast-xml-parser": "^5.11.1", + "hot-patcher": "^2.0.1", + "layerr": "^3.0.0", + "md5": "^2.3.0", + "minimatch": "^9.0.9", + "nested-property": "^4.0.0", + "node-fetch": "^3.3.2", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webdav/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" + }, + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "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/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..da2e69a --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "communications", + "private": true, + "version": "0.29.0", + "type": "module", + "scripts": { + "build": "vite build && vite build --config vite.delivery.config.js && vite build --config vite.flow.config.js && node ./scripts/install-assets.mjs", + "build:dev": "vite build --mode development && vite build --config vite.delivery.config.js --mode development && vite build --config vite.flow.config.js --mode development && node ./scripts/install-assets.mjs", + "test:unit": "node --test tests/unit/*.test.mjs", + "test:e2e": "playwright test", + "test:e2e:libresign": "playwright test tests/e2e/libresign-event.spec.mjs", + "lint": "eslint src tests scripts vite*.js playwright.config.mjs", + "audit": "npm audit --audit-level=high" + }, + "dependencies": { + "@codemirror/autocomplete": "6.20.4", + "@codemirror/commands": "6.11.1", + "@codemirror/language": "6.13.1", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.15", + "@mdi/js": "7.4.47", + "@nextcloud/vue": "9.14.1", + "@uiw/codemirror-theme-material": "4.25.12", + "vue": "^3.5.18", + "vue-codemirror6": "1.7.0" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@playwright/test": "1.65.0", + "@typescript-eslint/parser": "8.72.2", + "@vitejs/plugin-vue": "^6.0.0", + "eslint": "10.14.0", + "eslint-plugin-vue": "10.12.1", + "globals": "17.14.0", + "sass": "1.108.1", + "vite": "7.3.8" + } +} diff --git a/scripts/install-assets.mjs b/scripts/install-assets.mjs new file mode 100644 index 0000000..dfb955a --- /dev/null +++ b/scripts/install-assets.mjs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs' + +for (const [dir, bundle, jsTarget, cssTarget] of [ + ['dist', 'management.js', 'js/management.js', 'css/management.css'], + ['dist-delivery', 'campaigns.js', 'js/campaigns.js', 'css/campaigns.css'], + ['dist-flow', 'flow.js', 'js/flow.js', 'css/flow.css'], +]) { + if (!existsSync(dir + '/' + bundle)) throw new Error('Missing build output: '+dir+'/'+bundle) + mkdirSync('js', { recursive: true }) + mkdirSync('css', { recursive: true }) + copyFileSync(dir+'/'+bundle, jsTarget) + const css = readdirSync(dir).find(file=>file.endsWith('.css')) + if (!css) throw new Error('Missing CSS build: '+dir) + copyFileSync(dir+'/'+css, cssTarget) + console.log('Installed '+jsTarget+' and '+cssTarget) +} diff --git a/src/AppHub.vue b/src/AppHub.vue new file mode 100644 index 0000000..08e4cd2 --- /dev/null +++ b/src/AppHub.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/src/CampaignAdmin.vue b/src/CampaignAdmin.vue new file mode 100644 index 0000000..3f81884 --- /dev/null +++ b/src/CampaignAdmin.vue @@ -0,0 +1,1520 @@ + + + + diff --git a/src/CampaignDelivery.vue b/src/CampaignDelivery.vue new file mode 100644 index 0000000..657689d --- /dev/null +++ b/src/CampaignDelivery.vue @@ -0,0 +1,187 @@ + + + + diff --git a/src/CommunicationsBoard.vue b/src/CommunicationsBoard.vue new file mode 100644 index 0000000..f3c1c41 --- /dev/null +++ b/src/CommunicationsBoard.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/src/bannerAppearance.css b/src/bannerAppearance.css new file mode 100644 index 0000000..ef785ca --- /dev/null +++ b/src/bannerAppearance.css @@ -0,0 +1,27 @@ +/* SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +/* + * Shared by editor and delivery. The built-in message type retains its native + * semantics. Neutral/custom override only presentation, never the event role. + */ +.cc-note-neutral.notecard { + --note-theme: var(--color-border-dark, var(--color-border)); + --note-background: var(--color-main-background); + background-color: var(--color-main-background) !important; + border-inline-start: 2px solid var(--color-border) !important; +} +.cc-note-custom.notecard { + /* Use a restrained tint to preserve readable text with either NC theme. */ + --note-background: color-mix(in srgb, var(--cc-selected-color) 18%, var(--color-main-background)); + /* Blend towards the user's text color to keep icons/borders distinguishable. */ + --note-theme: color-mix(in srgb, var(--cc-selected-color) 45%, var(--color-main-text) 55%); + background-color: var(--note-background) !important; +} +.cc-note-no-icon.notecard .notecard__icon { + display: none !important; +} +.cc-note-empty-icon { display:none !important; } +.cc-banner-note-icon { + flex: none; + color: var(--note-theme); +} diff --git a/src/bannerAppearance.js b/src/bannerAppearance.js new file mode 100644 index 0000000..d098a09 --- /dev/null +++ b/src/bannerAppearance.js @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +// A stable, explicit icon catalog; only paths from @mdi/js are rendered. +// IDs are compatible with the names offered in Announcement Banner 2.8.1. +// Do not load SVG, CSS, JavaScript or network assets from campaign records. +import { + mdiInformation, mdiInformationOutline, mdiCheckboxMarkedCircle, mdiAlert, + mdiAlertDecagram, mdiAlertCircle, mdiAlertOctagon, mdiBellOutline, mdiBellRingOutline, + mdiBullhornOutline, mdiCalendarBlankOutline, mdiCalendarClock, mdiClockAlertOutline, + mdiShieldCheckOutline, mdiShieldAlertOutline, mdiStarOutline, mdiLock, mdiWrench, + mdiProgressWrench, mdiBroom, mdiServer, mdiServerOff, mdiDatabase, mdiCloud, + mdiCloudAlert, mdiWifiOff, mdiSync, mdiUpdate, mdiDownload, mdiUpload, mdiEmail, + mdiFlag, mdiGift, mdiPartyPopper, mdiRocketLaunch, mdiNewBox, mdiLightningBolt, + mdiHelpCircle, mdiCheckCircle, mdiCheckDecagram, mdiCloseCircle, mdiCloseOctagon, +} from '@mdi/js' +import './bannerAppearance.css' + +export const DEFAULT_BANNER_COLOR = '#6748AC' +export const BANNER_COLOR_PALETTE = [ + {color:'#6748AC',name:'Roxo'}, + {color:'#0B78B9',name:'Azul'}, + {color:'#157347',name:'Verde'}, + {color:'#B65E0A',name:'Laranja'}, + {color:'#B33045',name:'Vermelho'}, + {color:'#596579',name:'Cinza'}, +] +// Own labeling, native MDI vector paths, ID parity with the reference app. +const ICONS = Object.freeze([ + ['megaphone','Megafone',mdiBullhornOutline], + ['bullhorn','Alto-falante',mdiBullhornOutline], + ['bell','Sino',mdiBellOutline], + ['bell-ring','Sino tocando',mdiBellRingOutline], + ['alert','Alerta',mdiAlert], + ['alert-circle','Alerta circular',mdiAlertCircle], + ['alert-octagon','Alerta octogonal',mdiAlertOctagon], + ['information','Informação',mdiInformation], + ['information-outline','Informação contornada',mdiInformationOutline], + ['check-circle','Confirmação',mdiCheckCircle], + ['check-decagram','Selo de confirmação',mdiCheckDecagram], + ['close-circle','Erro circular',mdiCloseCircle], + ['close-octagon','Erro octogonal',mdiCloseOctagon], + ['shield-alert','Escudo de alerta',mdiShieldAlertOutline], + ['shield-check','Escudo com confirmação',mdiShieldCheckOutline], + ['lock','Cadeado',mdiLock], + ['wrench','Ferramenta',mdiWrench], + ['progress-wrench','Manutenção',mdiProgressWrench], + ['broom','Limpeza',mdiBroom], + ['server','Servidor',mdiServer], + ['server-off','Servidor indisponível',mdiServerOff], + ['database','Banco de dados',mdiDatabase], + ['cloud','Nuvem',mdiCloud], + ['cloud-alert','Alerta da nuvem',mdiCloudAlert], + ['wifi-off','Rede desconectada',mdiWifiOff], + ['sync','Sincronização',mdiSync], + ['update','Atualização',mdiUpdate], + ['download','Baixar',mdiDownload], + ['upload','Enviar',mdiUpload], + ['calendar','Calendário',mdiCalendarBlankOutline], + ['calendar-clock','Data e hora',mdiCalendarClock], + ['clock-alert','Prazo urgente',mdiClockAlertOutline], + ['email','E-mail',mdiEmail], + ['flag','Bandeira',mdiFlag], + ['star','Estrela',mdiStarOutline], + ['gift','Presente',mdiGift], + ['party-popper','Celebração',mdiPartyPopper], + ['rocket-launch','Lançamento',mdiRocketLaunch], + ['new-box','Novidade',mdiNewBox], + ['lightning-bolt','Energia',mdiLightningBolt], + ['help-circle','Ajuda',mdiHelpCircle], + ['info','Informação (legado)',mdiInformation], + ['success','Sucesso (legado)',mdiCheckboxMarkedCircle], + ['warning','Aviso (legado)',mdiAlert], + ['error','Erro (legado)',mdiAlertDecagram], + ['shield','Escudo (legado)',mdiShieldCheckOutline], +]) +const ICON_PATHS = Object.freeze(Object.fromEntries(ICONS.map(([id,label,path])=>[id,path]))) +export const BANNER_ICON_OPTIONS = [ + {value:'auto',label:'Ícone de acordo com o tipo de mensagem'}, + {value:'none',label:'Sem ícone'}, + ...ICONS.map(([value,label,path])=>({value,label,path})), +] +const SEMANTIC_TYPES = new Set(['info','success','warning','error']) + +export function bannerAppearance(campaign={}) { + const theme = typeof campaign.theme==='string' ? campaign.theme : 'default' + const nativeType = SEMANTIC_TYPES.has(theme) ? theme : 'info' + const choice = BANNER_ICON_OPTIONS.some(i=>i.value===campaign.banner_icon) + ? campaign.banner_icon : 'auto' + const rawColor = typeof campaign.banner_color==='string' ? campaign.banner_color : '' + const color = /^#[0-9a-fA-F]{3}$/.test(rawColor) + ? '#' + [...rawColor.slice(1)].map(char=>char+char).join('') + : /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(rawColor) ? rawColor : DEFAULT_BANNER_COLOR + const noIcon = choice==='none' || (choice==='auto' && (theme==='neutral'||theme==='custom')) + const background = 'color-mix(in srgb, '+color+' 22%, var(--color-main-background))' + const accent = 'color-mix(in srgb, '+color+' 45%, var(--color-main-text) 55%)' + const style = theme==='neutral' ? { + '--note-background':'var(--color-main-background)', + '--note-theme':'var(--color-border-dark, var(--color-border))', + 'background-color':'var(--color-main-background) !important', + 'border-inline-start':'2px solid var(--color-border) !important', + } : theme==='custom' ? { + '--cc-selected-color':color, + '--note-background':background, + '--note-theme':accent, + 'background-color':background+' !important', + } : {} + return { + type:nativeType, + noteClass:{ + 'cc-note-neutral':theme==='neutral', + 'cc-note-custom':theme==='custom', + 'cc-note-no-icon':noIcon, + }, + style, + hideIcon:noIcon, + iconPath:choice!=='auto'&&choice!=='none' ? ICON_PATHS[choice] : null, + } +} diff --git a/src/board.css b/src/board.css new file mode 100644 index 0000000..3cb7cf1 --- /dev/null +++ b/src/board.css @@ -0,0 +1,64 @@ +/* SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +.cc-board-app {width:100%; max-width:none; overflow:auto;} +.cc-board-main {max-width:980px; margin:0 auto; padding:28px clamp(12px,4vw,40px) 64px;} +.cc-board-heading {display:flex;align-items:center;gap:17px;margin-bottom:26px;} +.cc-board-heading h1 {font-size:clamp(25px,2.5vw,34px);margin:0 0 6px;} +.cc-board-heading p {margin:0;color:var(--color-text-maxcontrast);} +.cc-board-search {display:flex;align-items:flex-end;gap:12px;margin:0 0 22px;} +.cc-board-search>div {flex:1;} +.cc-board-article {border:1px solid var(--color-border);border-radius:var(--border-radius-large,12px); + padding:22px 24px;margin-bottom:16px;min-width:0;overflow-wrap:anywhere;} +.cc-board-article h2 {font-size:21px;margin:12px 0 15px;} +.cc-board-article .rich-text--wrapper img {max-width:100%;height:auto;} +.cc-board-metadata {display:flex;flex-wrap:wrap;gap:10px;align-items:center; + color:var(--color-text-maxcontrast);font-size:12px;} +.cc-board-chip {border:1px solid var(--color-border);border-radius:15px;padding:2px 9px; + background:var(--color-background-hover);} +.cc-board-article-actions {display:flex;justify-content:space-between;align-items:center;gap:12px; + margin-top:18px;padding-top:12px;border-top:1px solid var(--color-border);} +.cc-board-article-actions>span {font-size:12px;color:var(--color-text-maxcontrast);} +.cc-board-pagination {display:flex;align-items:center;justify-content:center;gap:15px;margin-top:25px;} +.cc-board-comments {margin-top:28px;} +.cc-board-comments h2 {font-size:20px;margin:0 0 18px;} +.cc-board-comments form {display:flex;flex-direction:column;gap:12px;margin-bottom:20px;} +.cc-board-comments form>button {align-self:flex-end;} +.cc-board-comment {padding:17px 0;border-bottom:1px solid var(--color-border);} +.cc-board-comment .rich-text--wrapper {margin-top:10px;} +.cc-board-cta {display:inline-block;color:var(--color-primary-element);margin-top:16px;} +.cc-board-empty-comments {color:var(--color-text-maxcontrast);padding:16px 0;} +@media(max-width:600px){.cc-board-main{padding:18px 12px 50px}.cc-board-article{padding:14px} +.cc-board-search{flex-wrap:wrap}.cc-board-search>button{width:100%}} + +.cc-inbox-filters {display:flex;gap:8px;flex-wrap:wrap;margin:-5px 0 20px;} +.cc-board-unread {border-inline-start:4px solid var(--color-primary-element);} +.cc-unread-label {color:var(--color-primary-element);font-weight:700;} +.cc-board-article-actions>button {max-width:100%;} + +.cc-mention-choices { + display:flex;flex-wrap:wrap;gap:5px;padding:6px 0 12px; + border-bottom:1px solid var(--color-border); +} +.cc-mention-choices button {max-width:100%;} + +/* Recipient-side opt-in stays within the Inbox rather than a third app area. */ +.cc-board-heading {flex-wrap:wrap;justify-content:space-between;} +.cc-gateway-preferences-button {margin-left:auto;} +.cc-gateway-preferences {border:1px solid var(--color-border);border-radius:12px; + padding:18px 20px;margin:-8px 0 22px;background:var(--color-main-background);} +.cc-gateway-preferences h3 {font-size:16px;margin:0 0 7px;} +.cc-gateway-preferences>p {font-size:13px;color:var(--color-text-maxcontrast);margin:0 0 14px;} +.cc-gateway-preference {padding:11px 0;border-bottom:1px solid var(--color-border);min-width:0;} +.cc-gateway-preference-heading {display:flex;justify-content:space-between;align-items:center;gap:12px;} +.cc-gateway-preference-heading>span {font-size:12px;color:var(--color-text-maxcontrast);text-align:right;} +.cc-gateway-preference>div:not(.cc-gateway-preference-heading) {margin:8px 0;} +.cc-gateway-preference .checkbox-radio-switch {margin:7px 0;} +.cc-gateway-preferences-hint {font-size:12px!important;} +@media(max-width:600px) { + .cc-gateway-preferences {padding:13px;} + .cc-gateway-preference-heading {flex-wrap:wrap;align-items:flex-start;} + .cc-gateway-preference-heading>span {margin-left:auto;} +} + +/* A direct-only link remains readable but is not a copy in the Inbox. */ +.cc-direct-channel-note {margin:12px 0;} diff --git a/src/components/AutomationBuilder.vue b/src/components/AutomationBuilder.vue new file mode 100644 index 0000000..5190c61 --- /dev/null +++ b/src/components/AutomationBuilder.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/src/components/CampaignPresentation.vue b/src/components/CampaignPresentation.vue new file mode 100644 index 0000000..a60fdfb --- /dev/null +++ b/src/components/CampaignPresentation.vue @@ -0,0 +1,84 @@ + + + + diff --git a/src/components/DeliveryChannelOption.vue b/src/components/DeliveryChannelOption.vue new file mode 100644 index 0000000..f8dfa11 --- /dev/null +++ b/src/components/DeliveryChannelOption.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/components/FlowCampaignPicker.vue b/src/components/FlowCampaignPicker.vue new file mode 100644 index 0000000..5cba2c8 --- /dev/null +++ b/src/components/FlowCampaignPicker.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/components/MarkdownEditor.vue b/src/components/MarkdownEditor.vue new file mode 100644 index 0000000..9eddb26 --- /dev/null +++ b/src/components/MarkdownEditor.vue @@ -0,0 +1,735 @@ + + + + + + diff --git a/src/components/markdown-editor/HeadingMenu.vue b/src/components/markdown-editor/HeadingMenu.vue new file mode 100644 index 0000000..854d419 --- /dev/null +++ b/src/components/markdown-editor/HeadingMenu.vue @@ -0,0 +1,264 @@ + + + + + + diff --git a/src/delivery.css b/src/delivery.css new file mode 100644 index 0000000..ae0736d --- /dev/null +++ b/src/delivery.css @@ -0,0 +1,99 @@ +/* SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +.cc-live-banner { + position: fixed; + z-index: 1100; + top: calc(var(--header-height, 50px) + 13px); + left: 50%; + transform: translateX(-50%); + width: min(670px, 95vw); + box-sizing: border-box; + /* Native NcNoteCard owns the semantic background, border and icon. */ + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large, 12px); + padding: 16px 18px; + box-shadow: 0 12px 35px #0003; + max-height: 60vh; + overflow-y: auto; +} +/* Several eligible banners may be visible simultaneously, as in Announcement + Banner. The stack scrolls within the viewport instead of hiding the header. */ +.cc-live-stack { + position: fixed; + top: calc(var(--header-height, 50px) + 13px); + left: 50%; + transform: translateX(-50%); + width: min(670px, 95vw); + max-height: calc(100vh - var(--header-height, 50px) - 28px); + z-index: 1100; + display: flex; + flex-direction: column; + gap: 12px; + overflow: auto; + scrollbar-gutter: stable; +} +.cc-live-stack .cc-live-banner { + position: relative; + flex-shrink: 0; + top: auto; + left: auto; + transform: none; + width: 100%; + max-height: none; + overflow: visible; +} +.cc-live-banner.notecard { margin: 0; } +.cc-live-banner > div { min-width: 0; width: 100%; } +.cc-live-content h2 { + font-size: 16px; + font-weight: 700; + line-height: 1.4; + margin: 0 0 8px; +} +.cc-live-content p, +.cc-live-content ul, +.cc-live-dialog-content p, +.cc-live-dialog-content ul { + margin: 6px 0; + line-height: 1.5; + overflow-wrap: anywhere; +} +.cc-live-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 10px; + margin-top: 14px; +} +.cc-live-dialog-content { padding: 14px 16px 20px; min-width:0; } + +/* Keep test-mode context available to screen readers without altering the + geometry or appearance of the published campaign. */ +.cc-preview-screenreader-note { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.cc-sponsored-label { + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--color-text-maxcontrast); + margin: 0 0 8px !important; +} +.cc-live-dialog-content[data-theme="warning"] { border-top: 4px solid var(--color-warning-text); } +.cc-live-dialog-content[data-theme="primary"] { border-top: 4px solid var(--color-primary-element); } +.cc-live-dialog-content[data-theme="info"] { border-top: 4px solid var(--color-info-text); } +.cc-live-dialog-content[data-theme="success"] { border-top: 4px solid var(--color-success-text); } +.cc-live-dialog-content[data-theme="error"] { border-top: 4px solid var(--color-error-text); } +.cc-live-dialog-content[data-theme="custom"] { border-top: 4px solid color-mix(in srgb, var(--cc-selected-color) 45%, var(--color-main-text) 55%); } +.cc-live-dialog-content[data-theme="neutral"] { border-top: none; } diff --git a/src/delivery.js b/src/delivery.js new file mode 100644 index 0000000..daad05e --- /dev/null +++ b/src/delivery.js @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { createApp } from 'vue' +import CampaignDelivery from './CampaignDelivery.vue' +import './delivery.css' + +function mount() { + const root = document.createElement('div') + root.id = 'communications-delivery' + document.body.appendChild(root) + createApp(CampaignDelivery).mount(root) +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount, { once: true }) +} else { + mount() +} diff --git a/src/flow.css b/src/flow.css new file mode 100644 index 0000000..8bbe328 --- /dev/null +++ b/src/flow.css @@ -0,0 +1,13 @@ +/* SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +.cc-flow-picker { + width:100%; + min-width:220px; + max-width:550px; + display:flex; + flex-direction:column; + gap:6px; + padding:6px; +} +.cc-flow-picker-label {font-size:13px;font-weight:600;} +.cc-flow-picker-hint {margin:0 0 8px;color:var(--color-text-maxcontrast);font-size:12px;} diff --git a/src/flow.js b/src/flow.js new file mode 100644 index 0000000..48f2945 --- /dev/null +++ b/src/flow.js @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +import { defineCustomElement } from 'vue' +import FlowCampaignPicker from './components/FlowCampaignPicker.vue' +import './flow.css' + +const tag='librecode-contextual-campaign-picker' +if (!window.customElements.get(tag)) { + window.customElements.define(tag,defineCustomElement(FlowCampaignPicker,{shadowRoot:false})) +} +function register() { + const api=window.OCA?.WorkflowEngine + if(!api?.registerOperator)return + api.registerOperator({ + id:'OCA\\Communications\\Service\\FlowCampaignOperation', + operation:'', + color:'#158570', + element:tag, + }) +} +if (document.readyState==='loading') { + document.addEventListener('DOMContentLoaded',register,{once:true}) +} else { + register() +} diff --git a/src/linkSafety.js b/src/linkSafety.js new file mode 100644 index 0000000..3c76851 --- /dev/null +++ b/src/linkSafety.js @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +// Same allowlist as CtaUrl::isAllowed; never render unsafe saved legacy schemes. +export function safeCtaLink(value) { + if (typeof value !== 'string' || !value || value.length > 1900 + // eslint-disable-next-line no-control-regex + || /[\x00-\x20\x7f"'<>\x60\\]/.test(value)) return '' + if (value.startsWith('/')) { + if (value.startsWith('//')) return '' + const webroot=window.OC?.webroot||'' + return webroot && !value.startsWith(webroot+'/') ? webroot+value : value + } + if (/^https?:\/\//i.test(value)) { + try { + const parsed=new URL(value) + if (!['https:','http:'].includes(parsed.protocol) || !parsed.hostname + || parsed.username || parsed.password) return '' + return value + } catch { return '' } + } + if (/^mailto:[^@\s]+@[^@\s]+$/i.test(value)) return value + if (/^tel:\+?[0-9().-]{3,30}$/i.test(value)) return value + return '' +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..8bf1033 --- /dev/null +++ b/src/main.js @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { createApp } from 'vue' +import AppHub from './AppHub.vue' +import './management.css' +import './board.css' +import './delivery.css' +const root=document.getElementById('communications-vue') +if (root) createApp(AppHub).mount(root) diff --git a/src/management.css b/src/management.css new file mode 100644 index 0000000..799c15c --- /dev/null +++ b/src/management.css @@ -0,0 +1,1150 @@ +/* SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later + * Layout only. Form controls, actions, dialogs, dates, rich text and messages + * are supplied by @nextcloud/vue rather than reimplemented. + */ +#content.app-communications > #communications-vue { + flex: 1 1 100%; + width: 100%; + min-width: 0; +} +#communications-vue { + height: calc(100vh - var(--header-height, 50px)); + min-height: 0; + background: var(--color-main-background); +} +.cc-native-app { + width: 100%; + max-width: none; + min-height: 0; + overflow: auto; +} +.cc-native-page { + width: 100%; + min-height: 100%; + max-height: none; + padding: 24px clamp(14px, 3vw, 48px) 44px; + box-sizing: border-box; + overflow-wrap: anywhere; +} +.cc-native-page *, +.cc-native-page *::before, +.cc-native-page *::after { + box-sizing: border-box; +} +.cc-native-page h1 { + font-size: clamp(25px, 2.5vw, 32px); + font-weight: 650; + margin: 8px 0; +} +.cc-native-page h2 { + font-size: 18px; + font-weight: 650; + margin-bottom: 14px; +} +.cc-native-page p { line-height: 1.5; } +.cc-native-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 24px; +} +.cc-native-heading > div:first-child { flex: 1; min-width: 260px; } +.cc-native-heading p { + color: var(--color-text-maxcontrast); + margin: 6px 0 0; +} +.cc-native-eyebrow { + letter-spacing: .09em; + font-size: 11px; + font-weight: 700; + color: var(--color-primary-element); +} +.cc-native-stats { + display: grid; + grid-template-columns: repeat(5,minmax(0,1fr)); + gap: 12px; + margin-bottom: 24px; +} +.cc-native-stat { + padding: 18px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large,12px); + background: var(--color-background-hover); + display: grid; + gap: 8px; +} +.cc-native-stat span { color: var(--color-text-maxcontrast); font-size: 12px; } +.cc-native-stat strong { font-size: 26px; font-weight: 650; font-variant-numeric:tabular-nums; } +.cc-native-panel { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large, 12px); + overflow: hidden; + background: var(--color-main-background); +} +.cc-native-list-head { + display: flex; + flex-wrap: wrap; + gap: 18px; + align-items: center; + justify-content: space-between; + padding: 18px; +} +.cc-native-list-head h2 { margin: 0; } +.cc-native-list-head p { + color: var(--color-text-maxcontrast); + font-size: 13px; + margin: 5px 0 0; +} +.cc-native-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} +.cc-native-filters > * { min-width: 175px; width: min(240px,100%); } +.cc-native-table-scroll { overflow-x: auto; max-width: 100%; } +.cc-native-table { + width: 100%; + min-width: 820px; + border-collapse: collapse; +} +.cc-native-table thead { + background: var(--color-background-hover); + color: var(--color-text-maxcontrast); +} +.cc-native-table th, +.cc-native-table td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid var(--color-border); + font-size: 13px; + vertical-align: middle; +} +.cc-native-table th { font-weight: 650; } +.cc-native-table td:first-child { min-width: 195px; } +.cc-native-table td strong { display:block; font-weight:650; } +.cc-native-table td small { display:block; color:var(--color-text-maxcontrast);margin-top:5px; } +.cc-native-table-actions { + display:table-cell; + vertical-align:middle !important; + text-align:right !important; + white-space:nowrap; +} +.cc-native-row-actions { + display:flex; + align-items:center; + justify-content:flex-end; + gap:8px; + min-height:36px; +} +.cc-native-status { + display: inline-flex; + padding: 4px 10px; + border-radius: 14px; + border: 1px solid var(--color-border); + color: var(--color-main-text); + background: var(--color-background-hover); + font-size: 12px; + white-space: nowrap; +} +.cc-native-status[data-status='active'] { border-color: var(--color-success); } +.cc-native-status[data-status='scheduled'] { border-color: var(--color-primary-element); } +.cc-native-caption { + margin: 20px 0; + color: var(--color-text-maxcontrast); + font-size: 12px; +} +.cc-native-heading-editor { + border-bottom: 1px solid var(--color-border); + padding-bottom: 17px; +} +.cc-native-pagination { + display:flex; + align-items:center; + justify-content:flex-end; + flex-wrap:wrap; + gap:12px; + border-top:1px solid var(--color-border); + padding:12px 18px; + color:var(--color-text-maxcontrast); + font-size:12px; +} +.cc-native-priority { display:flex; flex-direction:column; justify-content:flex-start; min-width:0; } +.cc-native-heading-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} +.cc-native-editor-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(265px, .7fr); + gap: clamp(18px, 2.5vw, 36px); + align-items: start; +} +.cc-native-section { + display: flex; + flex-direction: column; + gap: 15px; + padding: 22px 0; + border-bottom: 1px solid var(--color-border); +} +.cc-native-section:first-child { padding-top: 5px; } +.cc-native-section:last-child { border: none; } +.cc-native-section h2 { margin: 0 0 4px; } +.cc-native-control { display:flex; flex-direction:column; gap:5px; } +.cc-native-control label, .cc-native-label { + font-size: 13px; + font-weight: 650; +} +.cc-native-pair { + display:grid; + grid-template-columns: 1fr 1fr; + align-items: start; + gap: 14px; +} +.cc-native-hint { color:var(--color-text-maxcontrast); font-size:12px; margin: 3px 0 0; } +.cc-native-rich { display:flex;flex-direction:column;gap:7px; } +.cc-native-markdown-toolbar { display:flex;gap:5px; flex-wrap:wrap; } +.cc-native-rich .rich-contenteditable, +.cc-native-rich [contenteditable='true'] { + min-height: 126px; + max-height: 380px; + overflow-y: auto; + white-space: pre-wrap; +} +.cc-native-preview { + border: 1px solid var(--color-border); + background: var(--color-background-hover); + border-radius: var(--border-radius-large,12px); + padding: 20px; + position: sticky; + top: 18px; + min-width: 0; +} +.cc-native-preview h2 { font-size: 15px; } +.cc-native-preview > p { + font-size: 12px; + color: var(--color-text-maxcontrast); +} +.cc-native-preview-card { + border: 1px solid var(--color-border); + border-left: 4px solid var(--color-primary-element); + background: var(--color-main-background); + border-radius: 10px; + padding: 17px; + font-size: 14px; + max-width: 100%; + word-break: break-word; +} +.cc-native-preview-card[data-presentation='modal'] { border-left-width: 1px; box-shadow: 0 8px 26px #0001; } +.cc-native-preview-card > strong { display:block; margin-bottom:13px; font-size:16px; } +.cc-native-preview-link { color:var(--color-primary-element); font-weight:650; } +.cc-native-editor-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; + align-items:center; + padding: 24px 0 32px; + margin-top: 16px; + border-top: 1px solid var(--color-border); + position: static; +} +.cc-native-feedback { margin-bottom:20px; } +.cc-native-dialog-preview { padding: 12px 16px 24px; } +.cc-native-dialog-preview h2 { font-size: 22px; margin: 12px 0; } +.cc-native-dialog-preview :is(p,ul,ol,blockquote) { line-height:1.55; } +.cc-native-dialog-preview > :last-child { margin-top:18px; } +.cc-native-dialog-eyebrow { color: var(--color-text-maxcontrast); font-size:12px; } +@media (max-width: 1090px) { + .cc-native-editor-layout { grid-template-columns: 1fr; } + .cc-native-preview { position:static; order:-1; } + .cc-native-stats { grid-template-columns:repeat(3,minmax(0,1fr)); } +} +@media (max-width: 640px) { + .cc-native-stats { grid-template-columns: repeat(2,minmax(0,1fr)); } + .cc-native-heading-actions { width:100%; } + .cc-native-pair { grid-template-columns:1fr; } + .cc-native-filters { width:100%; } + .cc-native-filters > * { flex:1; min-width:160px; } + .cc-native-page { padding:14px 12px 28px; } +} + +.cc-native-metrics { padding: 12px 18px 24px; } +.cc-native-metrics > p { font-size:13px; color:var(--color-text-maxcontrast); } +.cc-native-metrics-grid { display:grid; grid-template-columns: repeat(2,minmax(0,1fr));gap:12px;margin:16px 0 26px; } +.cc-native-metrics-grid section { display:flex;flex-direction:column;gap:6px;border:1px solid var(--color-border);border-radius:10px;padding:16px; } +.cc-native-metrics-grid strong { font-size:25px; font-variant-numeric:tabular-nums; } +.cc-native-metrics-grid span { color:var(--color-text-maxcontrast);font-size:12px; } + +/* Assisted automation chooser: interaction uses official Nextcloud Vue controls. */ +.cc-automation { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} +.cc-automation-intents { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 10px; +} +.cc-automation-choice, +.cc-automation-source { + border: 1px solid var(--color-border); + background: var(--color-main-background); + border-radius: var(--border-radius-large,12px); + padding: 13px; + min-width: 0; +} +.cc-automation-choice[data-selected="true"], +.cc-automation-source[data-selected="true"] { + border-color: var(--color-primary-element); + background: var(--color-background-hover); +} +.cc-automation-choice button, +.cc-automation-source button { + width: 100%; + justify-content: flex-start; + min-height: 39px; +} +.cc-automation-choice p, +.cc-automation-source p { + font-size: 12px; + line-height: 1.45; + color: var(--color-text-maxcontrast); + margin: 7px 2px 0; +} +.cc-automation-explorer { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large,12px); + padding: 15px; + display: flex; + flex-direction: column; + gap: 15px; +} +.cc-automation-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.cc-automation-heading > div:first-child { flex:1; min-width:180px; } +.cc-automation-heading h3, +.cc-automation-actions h3 { font-size:14px;font-weight:650;margin:0 0 4px; } +.cc-automation-heading > :last-child:not(:first-child) { min-width:200px; } +.cc-automation-sources { + display: grid; + grid-template-columns: repeat(2,minmax(0,1fr)); + gap: 10px; +} +.cc-automation-actions { display:flex;flex-direction:column;gap:10px; } +.cc-automation-action { + display:flex; + flex-direction:column; + align-items:flex-start; + gap:6px; + border:1px solid var(--color-border); + border-radius:8px; + padding:9px; +} +.cc-automation-action[data-selected="true"] { border-color:var(--color-primary-element); } +.cc-automation-action span { color:var(--color-text-maxcontrast);font-size:12px; } +.cc-automation-placement { max-width:100%; } +.cc-automation-flow { display:flex; flex-wrap:wrap; gap:8px;align-items:center; padding-top:10px; } +.cc-automation-flow p { width:100%; } +.cc-automation-summary { + border-top: 1px solid var(--color-border); + padding-top: 12px; + font-size: 13px; +} +.cc-automation-summary strong { font-weight:650; } +.cc-automation-summary p { margin:5px 0 0; } +.cc-automation-error { color:var(--color-error); } +@media(max-width:640px) { + .cc-automation-intents, + .cc-automation-sources { grid-template-columns:1fr; } + .cc-automation-heading > :last-child:not(:first-child) {width:100%;min-width:0;} +} + +.cc-automation-more {display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;} +.cc-automation-more span {font-size:12px;color:var(--color-text-maxcontrast);} +.cc-native-automation-review {display:grid;gap:8px;margin:12px 0;font-size:12px;} +.cc-native-automation-review div {display:grid;gap:3px;} +.cc-native-automation-review dt {font-weight:650;color:var(--color-main-text);} +.cc-native-automation-review dd {margin:0;color:var(--color-text-maxcontrast);} + +/* 2026-11: compact campaign wizard matching Nextcloud's native form patterns. */ +.cc-native-heading-editor { + border-bottom: 0; + margin-bottom: 12px; + padding-bottom: 0; +} +.cc-wizard-heading { min-width:0; } +.cc-wizard-heading h1 { margin: 8px 0 2px; } +.cc-wizard-heading p {margin:4px 0;} +.cc-wizard-steps { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding: 0 0 12px; + border-bottom: 1px solid var(--color-border); +} +.cc-wizard-steps button { min-width:0; } +.cc-native-editor-layout { + grid-template-columns: minmax(0,1fr) minmax(285px,335px); + gap: clamp(14px,1.8vw,22px); + align-items:start; +} +.cc-wizard-main { + min-width:0; + border:1px solid var(--color-border); + border-radius: var(--border-radius-large,12px); + padding:18px 20px 0; + background:var(--color-main-background); +} +.cc-wizard-step-content { min-width:0; } +.cc-wizard-step-content > .cc-native-section { + padding: 15px 0 20px; +} +.cc-wizard-step-content > .cc-native-section:first-of-type {padding-top:5px;} +.cc-wizard-step-title { padding:0 0 12px; border-bottom:1px solid var(--color-border); } +.cc-wizard-step-title h2 {margin:0 0 3px;font-size:18px;} +.cc-wizard-step-title p {font-size:13px;color:var(--color-text-maxcontrast);margin:0;} +.cc-wizard-footer { + margin-top: 18px; + padding:15px 0 17px; + justify-content:space-between; + position:static; + background:none; +} +.cc-wizard-footer-actions { display:flex;flex-wrap:wrap;align-items:center;gap:10px; } +.cc-wizard-error {margin-bottom:16px;} + +.cc-automation { + gap: 0; +} +.cc-automation-title { + margin:0 0 3px!important; + font-size:19px!important; +} +.cc-automation-lead { margin:0 0 14px; } +.cc-automation-intents { + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:9px; +} +.cc-automation-intent { + min-width:0; + border:1px solid var(--color-border); + border-radius:var(--border-radius-large,10px); + padding:9px 8px 11px; + display:flex; + flex-direction:column; + justify-content:start; + gap:4px; +} +.cc-automation-intent[data-selected="true"] { + border-color:var(--color-primary-element); + background:var(--color-background-hover); +} +.cc-automation-intent > button { + width:100%; + align-self:flex-start; + min-height:38px; + justify-content:flex-start; + text-align:left; + white-space:normal; + line-height:1.25; + font-size:12px; + padding-inline:8px; +} +.cc-automation-intent p { + margin:0 3px; + font-size:11px; + line-height:1.4; + color:var(--color-text-maxcontrast); +} +.cc-automation-setup { + margin-top:18px; + padding-top:15px; + border-top:1px solid var(--color-border); + display:flex; + flex-direction:column; + gap:15px; +} +.cc-automation-section-heading h3 { + font-size:15px; + font-weight:650; + margin:0 0 3px; +} +.cc-automation-section-heading p { + margin:0; +} +.cc-automation-fields { + display:grid; + grid-template-columns:minmax(0,1fr) minmax(0,1fr); + gap:15px; + align-items:start; +} +.cc-automation-app-field label, +.cc-automation-visit label { + font-size:13px; + font-weight:650; +} +.cc-automation-app-field label span { color:var(--color-error); } +.cc-automation-app-field .v-select, +.cc-automation-visit .v-select { + width:100%; + max-width:100%; +} +.cc-automation-option { + display:flex; + align-items:center; + gap:9px; + min-width:0; +} +.cc-automation-option > span:last-child {display:flex;flex-direction:column;gap:2px;min-width:0;} +.cc-automation-option strong {font-size:13px;font-weight:650;} +.cc-automation-option small {font-size:11px;color:var(--color-text-maxcontrast);white-space:normal;} +.cc-automation-selected {display:flex;gap:6px;align-items:center;font-size:13px;} +.cc-automation-action-field { min-width:0; } +.cc-automation-action-list { + display:flex; + flex-direction:column; + border:1px solid var(--color-border); + border-radius:8px; + overflow:hidden; +} +.cc-automation-action-row { + min-width:0; + border-bottom:1px solid var(--color-border); +} +.cc-automation-action-row:last-child {border-bottom:0;} +.cc-automation-action-row[data-selected="true"] { + background:var(--color-background-hover); +} +.cc-automation-action-row > div { min-width:0; } +.cc-automation-action-row button { + width:100%; + min-height:36px; + max-width:none; + text-align:left; + justify-content:flex-start; + border:0; + border-radius:0; +} +.cc-automation-action-field .cc-native-hint {margin-top:6px;} +.cc-automation-empty { + display:flex; + align-items:center; + border:1px dashed var(--color-border); + border-radius:8px; + min-height:92px; + padding:12px; + font-size:12px; + color:var(--color-text-maxcontrast); +} +.cc-automation-visit {max-width:480px;} +.cc-automation-result { + padding:10px 12px; + border-radius:8px; + border:1px solid var(--color-border); + background:var(--color-background-hover); +} +.cc-automation-result-label {display:flex;gap:8px;align-items:center;min-width:0;} +.cc-automation-result-label strong {font-size:13px;overflow-wrap:anywhere;} +.cc-automation-result p {margin:3px 0 0 26px;font-size:12px;color:var(--color-text-maxcontrast);} +.cc-automation-result p.cc-automation-error {color:var(--color-error);} +.cc-automation-flow {padding-top:8px;} +.cc-automation-flow p {margin:0;} +.cc-automation-action-field fieldset {padding:0;margin:0;min-width:0;border:0;} + +.cc-wizard-sidebar { + display:flex; + flex-direction:column; + gap:12px; + min-width:0; + position:sticky; + top:16px; +} +.cc-wizard-summary, +.cc-wizard-preview { + border:1px solid var(--color-border); + border-radius:var(--border-radius-large,12px); + background:var(--color-background-hover); + padding:14px; + min-width:0; +} +.cc-wizard-summary h2, +.cc-wizard-preview h2 { + display:flex; + align-items:center; + gap:8px; + font-size:15px; + font-weight:650; + margin:0 0 12px; +} +.cc-wizard-summary-list {margin:0;} +.cc-wizard-summary-list > div { + display:grid; + grid-template-columns:20px minmax(75px, .83fr) minmax(0,1.15fr); + gap:7px; + padding:9px 0; + align-items:center; + border-bottom:1px solid var(--color-border); + min-width:0; +} +.cc-wizard-summary-list > div:first-child {padding-top:2px;} +.cc-wizard-summary-list > div:last-child {border:0;padding-bottom:0;} +.cc-wizard-summary-list dt {font-size:12px;font-weight:650;} +.cc-wizard-summary-list dd { + margin:0; + font-size:12px; + line-height:1.35; + color:var(--color-main-text); + overflow-wrap:anywhere; +} +.cc-wizard-preview-content { + background:var(--color-main-background); + border:1px solid var(--color-border); + border-left:3px solid var(--color-primary-element); + border-radius:8px; + padding:13px; + margin:0 0 12px; +} +.cc-wizard-preview-content[data-presentation="modal"] { + border-left-width:1px; + box-shadow:0 2px 12px #0001; +} +/* The compact preview uses the same native NcNoteCard severity as the live banner. */ +.cc-wizard-preview-notecard.notecard { margin:0 0 12px; padding:13px; min-width:0; } +.cc-wizard-preview-notecard > div { min-width:0; } +.cc-wizard-preview-content > strong, +.cc-wizard-preview-notecard strong {display:block;margin-bottom:6px;font-size:14px;line-height:1.3;} +.cc-wizard-preview-message { + max-height:125px; + overflow-y:auto; + font-size:12px; + line-height:1.45; +} +.cc-wizard-preview-message :is(p,ul,ol) {font-size:12px;margin:0 0 5px;} +.cc-wizard-preview-message :last-child {margin-bottom:0;} +.cc-wizard-preview-content .cc-native-preview-link, +.cc-wizard-preview-notecard .cc-native-preview-link {display:inline-block;margin-top:8px;} +@media(max-width:1180px) { + .cc-native-editor-layout {grid-template-columns:1fr;} + .cc-wizard-sidebar {position:static;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);} +} +@media(max-width:800px) { + .cc-automation-intents {grid-template-columns:repeat(2,minmax(0,1fr));} + .cc-automation-fields {grid-template-columns:1fr;} + .cc-wizard-sidebar {grid-template-columns:1fr;} +} +@media(max-width:490px) { + .cc-wizard-main {padding:14px 12px 0;} + .cc-wizard-steps {gap:4px;} + .cc-wizard-steps button {font-size:11px;flex:1;} + .cc-automation-intents {grid-template-columns:1fr 1fr;} + .cc-wizard-footer {gap:10px;} + .cc-wizard-footer-actions {margin-left:auto;} +} + +/* Nextcloud's global dl/dt/dd styles must not inflate the compact review. */ +.cc-wizard-summary-list > div { + grid-template-columns:18px minmax(66px,82px) minmax(0,1fr); + gap:9px; + min-height:34px; + padding:6px 0; +} +.cc-wizard-summary-list > div:first-child {padding-top:0;} +.cc-wizard-summary-list > div:last-child {padding-bottom:0;} +.cc-wizard-summary-list > div > :is(dt,dd) { + display:block; + float:none !important; + width:auto !important; + height:auto !important; + min-height:0 !important; + min-width:0; + margin:0 !important; + padding:0 !important; + line-height:1.35 !important; + text-align:left; +} +.cc-wizard-summary-list > div > dt {font-size:12px;font-weight:650;} +.cc-wizard-summary-list > div > dd {font-size:12px;font-weight:400;} +.cc-wizard-summary-list > div > .icon-vue { + width:18px !important; + height:18px !important; + min-width:18px !important; + min-height:18px !important; +} +.cc-wizard-summary-list > div > .icon-vue svg { + width:18px !important; + height:18px !important; +} + +/* Align full-width native action options, including the built-in radio icons. */ +.cc-automation-action-field fieldset, +.cc-automation-action-field .cc-automation-action-list, +.cc-automation-action-field .cc-automation-action-row { + width:100% !important; + max-width:none !important; + align-self:stretch; +} +.cc-automation-action-row :is(button, .button-vue) { + width:100% !important; + max-width:none !important; + min-height:39px; + justify-content:flex-start !important; +} +.cc-automation-intent button *, +.cc-automation-intent .button-vue__text { + white-space:normal !important; + overflow-wrap:break-word; +} + +/* Keep step controls readable at mobile/zoom widths. */ +.cc-wizard-step-short {display:none;} +@media(max-width:640px) { + .cc-wizard-step-long {display:none;} + .cc-wizard-step-short {display:inline;} + .cc-wizard-steps {gap:5px;} + .cc-wizard-steps button { + flex:1 1 0; + min-width:0; + padding-inline:6px; + font-size:12px; + } +} + +/* v0.11: full-card native buttons; compact native event and web-route selects. */ +.cc-automation-intents { + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:10px; +} +.cc-automation-intent-card { + display:flex !important; + flex-direction:column; + width:100% !important; + max-width:none !important; + min-width:0; + min-height:89px !important; + height:auto !important; + padding:12px 13px !important; + border:1px solid var(--color-border) !important; + border-radius:var(--border-radius-large,12px) !important; + text-align:left; + align-items:stretch !important; + justify-content:flex-start !important; + gap:8px; + white-space:normal; +} +.cc-automation-intent-card[aria-pressed="true"] { + border-color:var(--color-primary-element) !important; + background:var(--color-background-hover) !important; + color:var(--color-main-text) !important; +} +.cc-automation-intent-card :is(.button-vue__wrapper,.button-vue__text) { + display:flex; + flex-direction:column; + align-items:flex-start; + gap:7px; + min-width:0; + white-space:normal; + width:100%; + text-align:left; +} +.cc-automation-intent-title { + display:flex; + align-items:center; + gap:8px; + color:var(--color-main-text); + font-weight:650; + line-height:1.2; + font-size:14px; +} +.cc-automation-intent-title strong {font-weight:650;} +.cc-automation-intent-description { + display:block; + color:var(--color-text-maxcontrast); + font-size:12px; + line-height:1.4; + white-space:normal; +} +.cc-automation-setup {margin-top:16px;gap:14px;} +.cc-automation-configuration {display:flex;flex-direction:column;gap:14px;max-width:760px;} +.cc-automation-configuration h3 {font-size:15px;font-weight:650;margin:0;} +.cc-automation-field {display:flex;flex-direction:column;gap:6px;min-width:0;} +.cc-automation-field label {font-size:13px;font-weight:650;} +.cc-automation-field :is(.v-select, .vue-select) {width:100%;max-width:none;} +.cc-automation-field .cc-native-hint {margin:2px 0;} +.cc-route-filters { display:grid; gap:10px; padding:12px; border:1px solid var(--color-border); + border-radius:10px; background:var(--color-main-background); } +.cc-route-filters h4 { font-size:13px; margin:0; } +.cc-route-filter { display:grid; grid-template-columns:minmax(100px,1fr) minmax(170px,1fr); + gap:7px 12px; align-items:center; } +.cc-route-filter label { font-size:12px; } +.cc-route-filter-select, .cc-route-filter-value { min-width:0; width:100%; min-height:38px; + padding:7px 10px; color:var(--color-main-text); background:var(--color-main-background); + border:1px solid var(--color-border); border-radius:var(--border-radius); + font:inherit; font-size:13px; } +.cc-route-filter :is(select,input):focus-visible { outline:2px solid var(--color-primary-element); outline-offset:2px; } +@media(max-width:600px) { .cc-route-filter {grid-template-columns:1fr;} } +.cc-automation-mode {min-width:0;} +.cc-automation-mode-options { + display:flex; + gap:9px; + flex-wrap:wrap; +} +.cc-automation-mode-options > * { + flex:0 1 auto; + min-width:175px; +} +.cc-automation-only-mode { + color:var(--color-text-maxcontrast); +} +.cc-automation-result {margin-top:3px;} +.cc-wizard-steps .icon-vue {min-width:18px;} +@media(max-width:800px) { + .cc-automation-intents {grid-template-columns:1fr;} + .cc-automation-intent-card {min-height:68px !important;} + .cc-automation-mode-options {flex-direction:column;} + .cc-automation-mode-options > * {min-width:0;} +} + +.cc-automation-webhook {display:flex;flex-direction:column;gap:8px;margin-top:12px;min-width:0;} +.cc-automation-webhook p {margin:0;} +.cc-automation-webhook pre { + white-space:pre-wrap; + overflow-wrap:anywhere; + font-size:12px; + line-height:1.4; + background:var(--color-main-background); + border:1px solid var(--color-border); + padding:12px; + border-radius:8px; + max-width:100%; +} + +/* Progressive campaign wizard: no placeholder sidebar or inactive controls. */ +.cc-native-editor-layout.cc-wizard-unconfigured { grid-template-columns:minmax(0,1fr); } +.cc-wizard-unconfigured .cc-wizard-main { max-width:960px; } +.cc-wizard-required-hint { font-size:12px; color:var(--color-text-maxcontrast); align-self:center; } +.cc-wizard-footer button:disabled { cursor:not-allowed; } +.cc-automation-setup { margin-top:16px; padding-top:14px; } +.cc-field-label { display:flex; align-items:center; gap:10px; } +.cc-app-help-popover { display:inline-flex; align-items:center; } +.cc-app-help-popover button { min-width:32px; min-height:32px; } +.cc-help-content { max-width:340px; padding:14px 16px; margin:0; font-size:13px; line-height:1.5; } +.cc-automation-option img.cc-app-icon, .cc-automation-selected img.cc-app-icon { + width:20px; height:20px; flex:none; object-fit:contain; +} +.cc-automation-selected img.cc-app-icon { width:18px; height:18px; } +.cc-schedule-inline { padding:10px 0 18px; } +.cc-schedule-inline h3 { font-size:14px; font-weight:650; margin:0; } +.cc-format-options { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } +.cc-format-card { width:100% !important; max-width:none !important; min-width:0; + min-height:94px !important; height:auto !important; white-space:normal; + border:1px solid var(--color-border) !important; border-radius:10px !important; + align-items:stretch !important; justify-content:flex-start !important; padding:12px !important; } +.cc-format-card[aria-pressed="true"] { border-color:var(--color-primary-element) !important; + background:var(--color-background-hover) !important; color:var(--color-main-text) !important; } +.cc-format-card :is(.button-vue__wrapper,.button-vue__text) { display:flex; flex-direction:column; + align-items:flex-start; gap:9px; white-space:normal; min-width:0; text-align:left; } +.cc-format-heading { display:flex; gap:8px; align-items:center; color:var(--color-main-text); } +.cc-format-card small { font-size:12px; line-height:1.4; color:var(--color-text-maxcontrast); white-space:normal; } +.cc-banner-type-settings { display:grid; gap:10px; } +.cc-banner-type-label .cc-native-label { display:block; font-size:13px; font-weight:650; } +.cc-banner-type-label .cc-native-hint { margin:4px 0 0; } +.cc-banner-type-options { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; } +.cc-banner-type-card { min-width:0; width:100% !important; height:auto !important; + min-height:56px !important; padding:10px !important; border-radius:9px !important; + border:1px solid var(--color-border) !important; + border-inline-start:4px solid var(--cc-banner-type-accent) !important; + background:color-mix(in srgb,var(--cc-banner-type-background) 55%,var(--color-main-background)) !important; + color:var(--color-main-text) !important; } +.cc-banner-type-card[data-type="info"] { --cc-banner-type-background:var(--color-info); --cc-banner-type-accent:var(--color-info-text); } +.cc-banner-type-card[data-type="success"] { --cc-banner-type-background:var(--color-success); --cc-banner-type-accent:var(--color-success-text); } +.cc-banner-type-card[data-type="warning"] { --cc-banner-type-background:var(--color-warning); --cc-banner-type-accent:var(--color-warning-text); } +.cc-banner-type-card[data-type="error"] { --cc-banner-type-background:var(--color-error); --cc-banner-type-accent:var(--color-error-text); } +.cc-banner-type-card[data-type="neutral"] { --cc-banner-type-background:var(--color-main-background); --cc-banner-type-accent:var(--color-border-dark, var(--color-border)); } +.cc-banner-type-card[data-type="custom"] { + --cc-banner-type-background:#6748AC; --cc-banner-type-accent:#6748AC; + background:color-mix(in srgb, var(--cc-banner-type-background) 22%, var(--color-main-background)) !important; +} +.cc-banner-type-card[aria-pressed="true"] { outline:2px solid var(--cc-banner-type-accent); outline-offset:-3px; } +.cc-banner-type-heading { display:flex; gap:7px; align-items:center; font-weight:600; font-size:12px; } +.cc-banner-type-heading .material-design-icon { flex-shrink:0; color:var(--cc-banner-type-accent); } +.cc-banner-custom-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; align-items:start; } +.cc-banner-custom-fields > * { min-width:0; } +.cc-banner-color-picker { display:flex; flex-direction:column; gap:8px; } +.cc-banner-color-trigger { max-width:100%; min-height:40px !important; } +.cc-banner-color-trigger .button-vue__text { display:flex; gap:9px; align-items:center; white-space:normal; } +.cc-banner-color-swatch { display:inline-block; flex:none; width:24px; height:24px; border:1px solid var(--color-border-dark, var(--color-border)); border-radius:5px; } +.cc-banner-icon-picker { display:flex; flex-direction:column; gap:8px; } +.cc-banner-icon-picker label { font-size:13px; font-weight:650; } +@media(max-width:640px) { .cc-banner-custom-fields { grid-template-columns:1fr; } } +@media(max-width:960px) { .cc-banner-type-options { grid-template-columns:repeat(2,minmax(0,1fr)); } } +.cc-format-summary { align-self:center; } +.cc-format-summary strong { font-size:13px; font-weight:650; } +.cc-progressive-details { padding:10px 12px; border:1px solid var(--color-border); + border-radius:10px; background:var(--color-main-background); min-width:0; } +.cc-progressive-details > summary { cursor:pointer; font-size:13px; font-weight:600; } +.cc-progressive-details[open] > summary { margin-bottom:13px; } +.cc-progressive-details .cc-native-pair { margin-top:12px; } +.cc-channel-details ul { list-style:none; margin:10px 0; padding:0; } +.cc-channel-details li { display:flex; flex-direction:column; gap:2px; padding:9px 0; + border-bottom:1px solid var(--color-border); font-size:12px; } +.cc-channel-details li span { color:var(--color-text-maxcontrast); } +.cc-channel-details a { font-size:12px; } +@media(max-width:640px) { .cc-format-options,.cc-schedule-inline .cc-native-pair {grid-template-columns:1fr;} } + +/* One Nextcloud app with a shared information architecture. */ +.cc-hub {width:100%;max-width:none;min-height:calc(100vh - var(--header-height,50px));overflow:auto;} +.cc-hub-shell {min-height:100%;background:var(--color-main-background);} +.cc-hub-top {display:flex;justify-content:space-between;align-items:center;gap:16px; + padding:24px clamp(14px,3vw,48px) 16px;} +.cc-hub-brand {min-width:0;} +.cc-hub-brand h1 {font-size:clamp(25px,2.5vw,32px);margin:0;} +.cc-hub-brand p {font-size:13px;color:var(--color-text-maxcontrast);margin:6px 0 0;} +.cc-hub-overline {display:block;font-size:11px;font-weight:650;letter-spacing:.08em; + color:var(--color-primary-element);text-transform:uppercase;margin-bottom:4px;} +.cc-hub-tabs {display:flex;align-items:center;gap:8px;padding:0 clamp(14px,3vw,48px) 12px; + border-bottom:1px solid var(--color-border);flex-wrap:wrap;} +.cc-hub-tabs button {min-height:40px;} +.cc-hub-count {font-size:11px;font-weight:700;padding:2px 7px;border-radius:12px; + background:var(--color-background-hover);color:var(--color-main-text);} +.cc-hub-admin .cc-native-page {padding-top:24px;} +.cc-hub-board .cc-board-main {padding-top:24px;} +.cc-hub-admin .cc-native-heading h1 {font-size:23px;} +.cc-hub-board .cc-board-heading {margin-bottom:18px;} +.cc-hub-board .cc-board-heading h2 {font-size:21px;margin:0 0 6px;} +@media(max-width:650px) { + .cc-hub-top {align-items:flex-start;flex-wrap:wrap;padding-top:16px;} + .cc-hub-top>button {width:100%;} +} + +/* 0.18.1: one Nextcloud application shell, never nested app-content panes. */ +.cc-hub-shell { width:100%; min-width:0; } +.cc-hub-pane { width:100%; min-width:0; } +.cc-hub-pane[style*="display: none"] { display:none !important; } +.cc-hub-admin .cc-native-app, +.cc-hub-board .cc-board-app { min-height:0; overflow:visible; } +.cc-hub-admin .cc-native-page {padding-top:20px;} +.cc-hub-board .cc-board-main { + max-width:1200px; + width:100%; + margin:0; + padding:20px clamp(14px,3vw,48px) 54px; +} +.cc-hub-board .cc-board-heading { margin-bottom:16px; } +.cc-hub-board .cc-board-heading h2 {margin:0 0 5px;font-size:18px;} +.cc-hub-board .cc-board-heading p {font-size:13px;} +.cc-hub-top {padding-top:22px;padding-bottom:15px;} +.cc-hub-brand h1 {font-size:clamp(23px,2.2vw,30px);} +.cc-hub-tabs {gap:14px;padding-bottom:0;} +.cc-hub-tabs .cc-hub-tab {border:0 !important;border-bottom:3px solid transparent !important; + border-radius:0 !important;min-height:46px;background:transparent !important; + color:var(--color-main-text) !important;padding:0 12px !important;} +.cc-hub-tabs .cc-hub-tab-active { + border-bottom-color:var(--color-primary-element) !important; + background:var(--color-background-hover) !important; + color:var(--color-main-text) !important; +} +.cc-hub-tabs .cc-hub-tab:hover {background:var(--color-background-hover) !important;} +.cc-hub-tabs .cc-hub-tab:focus-visible {outline:2px solid var(--color-primary-element);outline-offset:-2px;} +.cc-hub-admin .cc-native-heading-overview {margin-bottom:15px;} +.cc-hub-admin .cc-native-heading-overview h2 {font-size:18px;margin:0;} +.cc-hub-admin .cc-native-heading-overview p {font-size:13px;margin-top:6px;} +.cc-hub-admin .cc-native-stats {display:flex;flex-wrap:wrap;gap:9px;margin-bottom:21px;} +.cc-hub-admin .cc-native-stat {flex:1 1 135px;min-width:112px;padding:11px 14px;gap:4px;} +.cc-hub-admin .cc-native-stat strong {font-size:22px;} +.cc-hub-admin .cc-native-stat span {font-size:12px;} +.cc-hub-admin .cc-editor-settings {margin-top:22px;} +.cc-hub-admin .cc-editor-delegation {max-width:720px;} +@media(max-width:640px) { + .cc-hub-top {padding-top:16px;} + .cc-hub-board .cc-board-main {padding:16px 12px 36px;} + .cc-hub-tabs {gap:4px;} + .cc-hub-tabs .cc-hub-tab {padding:0 8px !important;} + .cc-hub-admin .cc-native-stat {flex-basis:125px;} +} + +/* A persistent inbox announcement uses the native dialog header and footer. + Keep rich text away from both edges; never put actions immediately after a paragraph. */ +.cc-board-preview-dialog { + min-width: 0; + max-height: min(60vh, 540px); + overflow-y: auto; + overflow-wrap: anywhere; + padding: 18px 24px 16px; +} +.cc-board-preview-dialog .rich-text--wrapper { + min-width: 0; + line-height: 1.55; +} +.cc-board-preview-dialog .rich-text--wrapper :is(img, video) { + max-width: 100%; + height: auto; +} +.cc-board-preview-dialog .rich-text--wrapper > :first-child { margin-block-start: 0; } +.cc-board-preview-dialog .rich-text--wrapper > :last-child { margin-block-end: 0; } +@media (max-width: 600px) { + .cc-board-preview-dialog { padding: 16px 16px 14px; } +} + +.cc-permanent-delete-confirm {padding:18px 24px;display:grid;gap:12px;} +.cc-permanent-delete-confirm p {margin:0;line-height:1.5;} + +/* Unified delivery. Presentation and notification channels share one area; + optional adapters are documented rather than shown as a second list. */ +.cc-unified-channels { + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:18px; + padding:16px; + margin:12px 0 18px; + border:1px solid var(--color-border); + border-radius:12px; + background:var(--color-background-hover); +} +.cc-unified-channel-group {min-width:0;display:flex;flex-direction:column;gap:9px;} +.cc-unified-channel-group + .cc-unified-channel-group { + border-inline-start:1px solid var(--color-border); + padding-inline-start:18px; +} +.cc-unified-channel-group h3 {font-size:14px;font-weight:650;margin:0;line-height:1.35;} +.cc-unified-channel-group .cc-native-hint {font-size:12px;margin:0;line-height:1.45;} +.cc-unified-channel-group .checkbox-radio-switch {margin:0;} +.cc-review-delivery-line {font-size:14px;font-weight:650;line-height:1.45;margin:0;overflow-wrap:anywhere;} +@media (max-width:900px) { + .cc-unified-channels {grid-template-columns:minmax(0,1fr);padding:14px;gap:16px;} + .cc-unified-channel-group + .cc-unified-channel-group { + border-inline-start:0;border-top:1px solid var(--color-border); + padding-inline-start:0;padding-top:14px; + } +} + +.cc-inbox-automatic {display:flex;gap:8px;align-items:center;margin:0; + color:var(--color-main-text);font-size:13px;font-weight:650;line-height:1.4;} +.cc-inbox-automatic .icon-vue {color:var(--color-success-text,var(--color-primary-element));flex:none;} + +/* Capability-aware channel controls. Unavailable channels have no inert + switch: installation or adapter requirements remain discoverable by pointer + and keyboard through a focusable help button. */ +.cc-delivery-channel-list {display:flex;flex-direction:column;min-width:0;} +.cc-delivery-channel { + min-width:0;padding:11px 0;border-bottom:1px solid var(--color-border); +} +.cc-delivery-channel:last-child {border-bottom:none;padding-bottom:0;} +.cc-delivery-channel-header { + display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0; +} +.cc-delivery-channel-control {min-width:0;flex:1 1 auto;line-height:1.35;} +.cc-delivery-channel-control .checkbox-radio-switch {min-width:0;margin:0;} +.cc-delivery-channel-control .checkbox-radio-switch__label {overflow-wrap:anywhere;} +.cc-delivery-channel-unavailable {font-size:13px;font-weight:600;overflow-wrap:anywhere;} +.cc-delivery-channel-status { + display:flex;align-items:center;justify-content:flex-end;gap:3px; + font-size:11px;color:var(--color-text-maxcontrast);flex:0 0 auto; + max-width:50%; +} +.cc-delivery-channel-status span {text-align:right;line-height:1.3;} +.cc-delivery-channel-help {min-width:28px !important;min-height:28px !important;flex:none;} +.cc-delivery-channel-help-content {max-width:310px;min-width:180px;padding:10px 12px;line-height:1.5;} +.cc-delivery-channel-help-content strong {display:block;margin-bottom:6px;} +.cc-delivery-channel-help-content p {margin:0;white-space:normal;overflow-wrap:anywhere;font-size:13px;} +.cc-delivery-channel-description {font-size:11px;line-height:1.4; + color:var(--color-text-maxcontrast);margin:4px 0 0;} +.cc-delivery-external-label {color:var(--color-text-maxcontrast); + font-size:11px;font-weight:650;letter-spacing:.025em;text-transform:uppercase; + margin:13px 0 0;padding-top:12px;border-top:1px solid var(--color-border);} +.cc-delivery-channel[data-availability="missing_app"], +.cc-delivery-channel[data-availability="adapter_missing"], +.cc-delivery-channel[data-availability="missing_app_and_adapter"] { + color:var(--color-main-text); +} +@media (max-width:600px) { + .cc-delivery-channel-header {flex-wrap:wrap;align-items:flex-start;} + .cc-delivery-channel-status {max-width:100%;margin-left:auto;} + .cc-delivery-channel-help-content {min-width:0;max-width:min(280px,calc(100vw - 48px));} +} + +/* Gateway channels remain part of the single "Avisar também" delivery model. */ +.cc-gateway-provider-details {margin-top:12px;border:1px solid var(--color-border); + border-radius:10px;padding:9px 13px;background:var(--color-main-background);} +.cc-gateway-provider-details>summary {display:flex;gap:12px;justify-content:space-between; + align-items:center;cursor:pointer;list-style:revert;font-size:13px;} +.cc-gateway-provider-details>summary>span {font-size:11px;color:var(--color-text-maxcontrast); + text-align:right;} +.cc-gateway-provider-details[open]>p.cc-native-hint {margin:12px 0 4px;} +.cc-gateway-provider-details .cc-delivery-channel:last-child {border-bottom:0;} + +/* v0.27.0: separate visual presentation, Inbox retention, and outbound channels. */ +.cc-delivery-section .cc-delivery-subheading {font-size:14px;font-weight:650;margin:12px 0 9px;} +.cc-delivery-section>.cc-native-hint {max-width:790px;line-height:1.5;} +.cc-delivery-empty-warning {margin:12px 0;} +.cc-format-card[aria-pressed="true"] {border-color:var(--color-primary-element);} +.cc-wizard-preview[data-presentation="none"] {border:1px solid var(--color-border);} +@media(max-width:640px){.cc-delivery-subheading{margin-top:8px;}} + +/* v0.27: avoid a tall empty Inbox column beside a dense channel column. + * History occupies a compact row; channel controls can use the full width. */ +@media (min-width:901px) { + .cc-delivery-section .cc-unified-channels {grid-template-columns:minmax(0,1fr);gap:13px;} + .cc-delivery-section .cc-unified-channel-group + .cc-unified-channel-group { + border-inline-start:0;border-top:1px solid var(--color-border); + padding-inline-start:0;padding-top:14px; + } + .cc-delivery-section .cc-delivery-channel-list { + display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:24px; + } + .cc-delivery-section .cc-gateway-provider-details {grid-column:1/-1;} +} + +/* v0.28: all publication destinations are peers in a single selector. */ +.cc-delivery-section .cc-delivery-selector { + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:0 22px; + border:1px solid var(--color-border); + border-radius:12px; + background:var(--color-background-hover); + padding:10px 17px 15px; +} +.cc-delivery-section .cc-delivery-selector>.cc-delivery-channel { + padding:13px 8px 12px; + border-bottom:1px solid var(--color-border); +} +.cc-delivery-section .cc-delivery-selector>.cc-delivery-channel[data-selected="true"] { + background:color-mix(in srgb,var(--color-primary-element) 6%,transparent); + border-radius:8px; +} +.cc-delivery-section .cc-delivery-selector .cc-gateway-provider-details { + grid-column:1/-1; + margin:12px 0 0; +} +.cc-delivery-section .cc-delivery-selector .cc-gateway-provider-details .cc-delivery-channel { + margin-inline:6px; +} +.cc-delivery-selection-feedback { + display:flex;flex-wrap:wrap;gap:6px 12px;align-items:baseline; + font-size:12px;padding:11px 13px; + border:1px solid var(--color-border); + border-radius:8px;background:var(--color-main-background); +} +.cc-delivery-selection-feedback strong {font-weight:650;white-space:nowrap} +.cc-delivery-selection-feedback span {color:var(--color-text-maxcontrast);overflow-wrap:anywhere} +.cc-delivery-comments {margin:2px 0 0 6px;} +@media(max-width:700px) { + .cc-delivery-section .cc-delivery-selector {grid-template-columns:minmax(0,1fr);padding-inline:13px;} + .cc-delivery-section .cc-delivery-selector .cc-gateway-provider-details {grid-column:1;} +} diff --git a/templates/main.php b/templates/main.php new file mode 100644 index 0000000..07df0ab --- /dev/null +++ b/templates/main.php @@ -0,0 +1,6 @@ + +
diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..19fc6a7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + base: './', + define: { + appName: JSON.stringify('communications'), + appVersion: JSON.stringify('0.29.0'), + 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env': '{}', + }, + build: { + outDir: 'dist', + emptyOutDir: true, + cssCodeSplit: false, + lib: { + entry: 'src/main.js', + name: 'LibreCodeCommunicationsAdmin', + formats: ['iife'], + fileName: () => 'management.js', + }, + rollupOptions: { + output: { + inlineDynamicImports: true, + assetFileNames: 'management[extname]', + }, + }, + }, +}) diff --git a/vite.delivery.config.js b/vite.delivery.config.js new file mode 100644 index 0000000..a0794bc --- /dev/null +++ b/vite.delivery.config.js @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins:[vue()], + base:'./', + define:{ + appName: JSON.stringify('communications'), + appVersion: JSON.stringify('0.29.0'), + 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env':'{}', + }, + build:{ + outDir:'dist-delivery', + emptyOutDir:true, + cssCodeSplit:false, + lib:{ + entry:'src/delivery.js', + name:'LibreCodeCommunicationsDisplay', + formats:['iife'], + fileName:()=> 'campaigns.js', + }, + rollupOptions:{output:{inlineDynamicImports:true,assetFileNames:'campaigns[extname]'}}, + }, +}) diff --git a/vite.flow.config.js b/vite.flow.config.js new file mode 100644 index 0000000..2603437 --- /dev/null +++ b/vite.flow.config.js @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins:[vue()], + base:'./', + define:{ + appName:JSON.stringify('communications'), + appVersion:JSON.stringify('0.29.0'), + 'process.env.NODE_ENV':JSON.stringify('production'), + 'process.env':'{}', + }, + build:{ + outDir:'dist-flow', + emptyOutDir:true, + cssCodeSplit:false, + lib:{ + entry:'src/flow.js', + name:'LibreCodeContextualCampaignFlow', + formats:['iife'], + fileName:()=> 'flow.js', + }, + rollupOptions:{output:{inlineDynamicImports:true,assetFileNames:'flow[extname]'}}, + }, +}) From 8f69dbd2931a5071d01702af563dec0e9740e8e2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:44:21 -0300 Subject: [PATCH 002/121] test: add quality gates and Nextcloud integration scenarios Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/nextcloud-e2e.yml | 49 ++++++ .github/workflows/quality.yml | 69 ++++++++ eslint.config.mjs | 26 +++ phpstan.neon.dist | 10 ++ phpunit.xml.dist | 8 + playwright.config.mjs | 23 +++ scripts/ci/bootstrap.sh | 117 ++++++++++++++ scripts/ci/php-lint.sh | 6 + tests/ci/compose.yml | 45 ++++++ tests/e2e/app-smoke.spec.mjs | 73 +++++++++ tests/e2e/libresign-event.spec.mjs | 153 ++++++++++++++++++ tests/manual/mvp_event_probe_v29.php | 86 ++++++++++ tests/manual/mvp_gateway_event_v29.php | 43 +++++ .../manual/mvp_libresign_native_event_v29.php | 43 +++++ .../manual/test_channel_only_dispatch_v27.php | 130 +++++++++++++++ tests/manual/test_flow_v08.php | 81 ++++++++++ tests/manual/test_gateway_dispatch_v26.php | 40 +++++ .../test_gateway_transport_contract_v26.php | 59 +++++++ tests/manual/test_immediate_visual_v28.php | 34 ++++ tests/manual/test_page_routes_v11.php | 86 ++++++++++ tests/manual/test_webhook_scope_v11.php | 57 +++++++ tests/php/Unit/CtaUrlTest.php | 42 +++++ .../php/Unit/NativeEventSubscriptionsTest.php | 40 +++++ tests/stubs/NextcloudInternals.php | 25 +++ tests/unit/linkSafety.test.mjs | 28 ++++ 25 files changed, 1373 insertions(+) create mode 100644 .github/workflows/nextcloud-e2e.yml create mode 100644 .github/workflows/quality.yml create mode 100644 eslint.config.mjs create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 playwright.config.mjs create mode 100755 scripts/ci/bootstrap.sh create mode 100755 scripts/ci/php-lint.sh create mode 100644 tests/ci/compose.yml create mode 100644 tests/e2e/app-smoke.spec.mjs create mode 100644 tests/e2e/libresign-event.spec.mjs create mode 100644 tests/manual/mvp_event_probe_v29.php create mode 100644 tests/manual/mvp_gateway_event_v29.php create mode 100644 tests/manual/mvp_libresign_native_event_v29.php create mode 100644 tests/manual/test_channel_only_dispatch_v27.php create mode 100644 tests/manual/test_flow_v08.php create mode 100644 tests/manual/test_gateway_dispatch_v26.php create mode 100644 tests/manual/test_gateway_transport_contract_v26.php create mode 100644 tests/manual/test_immediate_visual_v28.php create mode 100644 tests/manual/test_page_routes_v11.php create mode 100644 tests/manual/test_webhook_scope_v11.php create mode 100644 tests/php/Unit/CtaUrlTest.php create mode 100644 tests/php/Unit/NativeEventSubscriptionsTest.php create mode 100644 tests/stubs/NextcloudInternals.php create mode 100644 tests/unit/linkSafety.test.mjs diff --git a/.github/workflows/nextcloud-e2e.yml b/.github/workflows/nextcloud-e2e.yml new file mode 100644 index 0000000..a8a3a3c --- /dev/null +++ b/.github/workflows/nextcloud-e2e.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +name: Nextcloud 36 E2E +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '19 4 * * 1' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: communications-nc36-${{ github.ref }} + cancel-in-progress: true +jobs: + compatibility: + name: Nextcloud 36 / ${{ matrix.database }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + database: [sqlite, pgsql, mariadb] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + cache: npm + - run: npm ci --include=dev --include=optional + - run: npm run build + - run: npx playwright install --with-deps chromium + - name: Provision isolated Nextcloud 36, database and optional LibreSign + run: bash scripts/ci/bootstrap.sh '${{ matrix.database }}' + env: + INSTALL_LIBRESIGN: ${{ matrix.database == 'sqlite' && '1' || '0' }} + - name: Verify publication and recipient isolation + run: npx playwright test tests/e2e/app-smoke.spec.mjs + - name: Verify generic LibreSign completion contract + if: matrix.database == 'sqlite' + run: npm run test:e2e:libresign + - name: Shutdown isolated test environment + if: always() + run: docker compose -f tests/ci/compose.yml -p communications-ci down --volumes --remove-orphans || true + env: + NC_TEST_PASSWORD: ephemeral-cleanup-only diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..44ebff9 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +name: Quality +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: communications-quality-${{ github.ref }} + cancel-in-progress: true +jobs: + php: + name: PHP lint, PHPUnit and static analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + extensions: mbstring, intl, dom, xml, zip, sqlite3, pdo_sqlite + coverage: none + tools: composer:v2 + - run: composer validate --no-check-publish --strict + - run: composer install --no-interaction --prefer-dist --no-progress + - run: composer lint + - run: composer test + - run: composer analyse + - run: composer audit + javascript: + name: JS lint, tests, build and high-severity audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + cache: npm + - run: npm ci --include=dev --include=optional + - run: npm run lint + - run: npm run test:unit + - run: npm run build + - run: npm run audit + reuse: + name: REUSE license compliance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0 + manifest: + name: Nextcloud App Store info.xml schema + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: curl --fail --silent --show-error --location https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd -o info.xsd + - uses: ChristophWurst/xmllint-action@36f2a302f84f8c83fceea0b9c59e1eb4a616d3c1 # v1.2 + with: + xml-file: ./appinfo/info.xml + xml-schema-file: ./info.xsd diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..04ec9af --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import eslint from '@eslint/js' +import vue from 'eslint-plugin-vue' +import globals from 'globals' +import tsParser from '@typescript-eslint/parser' + +export default [ + { ignores: ['node_modules/**', 'vendor/**', 'js/**', 'css/**', 'dist*/**', 'playwright-report/**', 'test-results/**'] }, + ...vue.configs['flat/essential'], + { files: ['src/**/*.vue'], languageOptions: { parserOptions: { parser: tsParser } } }, + { + files: ['src/**/*.{js,vue}', 'scripts/*.mjs', 'tests/**/*.{mjs,cjs,js}', 'vite*.js', 'playwright.config.mjs'], + languageOptions: { + ecmaVersion: 'latest', + globals: { ...globals.browser, ...globals.node, OC: 'readonly' }, + }, + rules: { + ...eslint.configs.recommended.rules, + 'no-unused-vars': 'off', + 'no-undef': 'error', + 'no-unreachable': 'error', + 'no-constant-condition': 'error', + }, + }, +] diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..d00d714 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,10 @@ +parameters: + level: 2 + paths: + - lib + scanFiles: + - tests/stubs/NextcloudInternals.php + scanDirectories: + - vendor/nextcloud/ocp/OCP + excludePaths: + - lib/Migration diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..45b8217 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,8 @@ + + + tests/php/Unit + lib + diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..9535ffc --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 180_000, + expect: { timeout: 15_000 }, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: process.env.NC_BASE_URL || 'http://127.0.0.1:18884', + browserName: 'chromium', + headless: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + launchOptions: { + ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE } : {}), + }, + }, +}) diff --git a/scripts/ci/bootstrap.sh b/scripts/ci/bootstrap.sh new file mode 100755 index 0000000..b9f3647 --- /dev/null +++ b/scripts/ci/bootstrap.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +set -euo pipefail +cd "$(dirname "$0")/../.." +export NC_TEST_PASSWORD="${NC_TEST_PASSWORD:-$(openssl rand -hex 16)}" +export NC_CONTAINER="${NC_CONTAINER:-communications-ci-nc36}" +export NC_PORT="${NC_PORT:-18884}" +export NC_BASE_URL="http://127.0.0.1:${NC_PORT}" +export NC_ADMIN_USER="qa_admin" NC_RECIPIENT_USER="poc_second" NC_UNRELATED_USER="qa_unrelated" +DB="${1:-sqlite}" +case "$DB" in sqlite|pgsql|mariadb) ;; *) echo 'Choose sqlite, pgsql or mariadb' >&2; exit 2;; esac +: "${LIBRESIGN_REF:=041d0cc4bcb810fb7403814a3fac692d9db8f69d}" +root="${NC_RUNTIME_DIR:-$PWD/.ci/runtime}" +export NC_RUNTIME_DIR="$root" +mkdir -p "$root" +if [[ ! -f "$root/nextcloud/version.php" ]]; then + if [[ -n "${NC36_LOCAL_SOURCE:-}" ]]; then + mkdir -p "$root/nextcloud" + rsync -rl --exclude '/config/' --exclude '/data/' --exclude '/custom_apps/' "$NC36_LOCAL_SOURCE/" "$root/nextcloud/" + else + archive_url="${NC36_ARCHIVE_URL:-https://download.nextcloud.com/server/daily/latest-master.tar.bz2}" + # Fail closed if the upstream archive is corrupted or lacks a digest. + curl --fail --location --retry 3 --show-error --output "$root/nextcloud.tar.bz2" "$archive_url" + curl --fail --location --retry 3 --show-error --output "$root/nextcloud.sha256" "${archive_url}.sha256" + expected_sha="$(awk 'NR == 1 { print $1 }' "$root/nextcloud.sha256")" + [[ "$expected_sha" =~ ^[a-fA-F0-9]{64}$ ]] || { echo 'Invalid Nextcloud archive checksum' >&2; exit 3; } + printf '%s %s\n' "$expected_sha" "$root/nextcloud.tar.bz2" | sha256sum --check --status + tar -xjf "$root/nextcloud.tar.bz2" -C "$root" + rm "$root/nextcloud.tar.bz2" "$root/nextcloud.sha256" + fi +fi +if ! grep -qE '\$OC_Version[[:space:]]*=[[:space:]]*(\[|array\()[[:space:]]*36,' "$root/nextcloud/version.php"; then + echo 'Not a Nextcloud 36 snapshot; stop instead of silently testing another version' >&2 + head -n 6 "$root/nextcloud/version.php" >&2 + exit 2 +fi +if [[ "${INSTALL_LIBRESIGN:-0}" == '1' && ! -f "$root/libresign/appinfo/info.xml" ]]; then + if [[ -n "${LIBRESIGN_LOCAL_SOURCE:-}" ]]; then + mkdir -p "$root/libresign" + rsync -rl "$LIBRESIGN_LOCAL_SOURCE/" "$root/libresign/" + else + git clone -q https://github.com/LibreSign/libresign.git "$root/libresign" + git -C "$root/libresign" checkout -q "$LIBRESIGN_REF" + docker run --rm --user "$(id -u):$(id -g)" -v "$root/libresign:/app" \ + -w /app composer:2 composer install --no-dev --no-interaction --no-progress + # A source checkout is not a release artifact: build the frontend assets + # that the Nextcloud app needs in order to load its signing UI. + (cd "$root/libresign" && npm ci && npm run build) + fi +fi +mkdir -p "$root/nextcloud/config" "$root/nextcloud/data" "$root/nextcloud/custom_apps" "$root/libresign" +# The test container runs as www-data (uid 33). Only mutable Nextcloud +# directories need ownership changes; the checked-out source stays read-only. +docker compose -f tests/ci/compose.yml -p "${NC_COMPOSE_PROJECT:-communications-ci}" up -d --no-deps nextcloud +if [[ "$DB" == pgsql ]]; then + docker compose -f tests/ci/compose.yml -p "${NC_COMPOSE_PROJECT:-communications-ci}" --profile postgresql up -d postgres + DB_SERVICE=postgres +elif [[ "$DB" == mariadb ]]; then + docker compose -f tests/ci/compose.yml -p "${NC_COMPOSE_PROJECT:-communications-ci}" --profile mariadb up -d mariadb + DB_SERVICE=mariadb +else + DB_SERVICE='' +fi +docker exec -u 0 "$NC_CONTAINER" chown -R 33:33 /var/www/html/config /var/www/html/data /var/www/html/apps +docker exec -u 0 "$NC_CONTAINER" chmod 0770 /var/www/html/config /var/www/html/data +# Nextcloud from a development archive may not have a writable custom app +# path configured by default. Keep the app source mounted independently. +docker exec -u 0 "$NC_CONTAINER" sh -c 'cat > /var/www/html/config/99-communications-ci.config.php <<"PHP" + [ + ["path" => "/var/www/html/apps", "url" => "/apps", "writable" => false], + ["path" => "/var/www/html/custom_apps", "url" => "/custom_apps", "writable" => true], +]]; +PHP +chown www-data:www-data /var/www/html/config/99-communications-ci.config.php' +if [[ -n "$DB_SERVICE" ]]; then + healthy='' + for attempt in $(seq 1 65); do + healthy="$(docker compose -f tests/ci/compose.yml -p "${NC_COMPOSE_PROJECT:-communications-ci}" ps --format json "$DB_SERVICE" | grep -o '"Health":"healthy"' || true)" + [[ -n "$healthy" ]] && break + sleep 2 + done + [[ -n "$healthy" ]] || { echo "$DB_SERVICE not healthy" >&2; exit 3; } +fi +install=(php occ maintenance:install --admin-user qa_admin --admin-pass "$NC_TEST_PASSWORD" --data-dir /var/www/html/data) +case "$DB" in + sqlite) install+=(--database sqlite);; + pgsql) install+=(--database pgsql --database-name nextcloud --database-host postgres --database-user nextcloud --database-pass "$NC_TEST_PASSWORD");; + mariadb) install+=(--database mysql --database-name nextcloud --database-host mariadb --database-user nextcloud --database-pass "$NC_TEST_PASSWORD");; +esac +if ! docker exec -u www-data "$NC_CONTAINER" php occ status --output json 2>/dev/null | grep -F '"installed":true' >/dev/null; then + docker exec -u www-data "$NC_CONTAINER" "${install[@]}" >/dev/null +fi +docker exec -u www-data "$NC_CONTAINER" php occ config:system:set trusted_domains 1 --value localhost >/dev/null +docker exec -u www-data "$NC_CONTAINER" php occ config:system:set trusted_domains 2 --value 127.0.0.1 >/dev/null +docker exec -u www-data "$NC_CONTAINER" php occ app:enable communications +if [[ "${INSTALL_LIBRESIGN:-0}" == 1 ]]; then + docker exec -u www-data "$NC_CONTAINER" php occ app:enable libresign +fi +for uid in poc_second qa_unrelated; do + if ! docker exec -u www-data "$NC_CONTAINER" php occ user:info "$uid" >/dev/null 2>&1; then + # Nextcloud can create the user and still report a hook failure. Never + # retry a potentially successful creation blindly; verify the account. + if ! docker exec -u www-data -e OC_PASS="$NC_TEST_PASSWORD" "$NC_CONTAINER" \ + php occ user:add --password-from-env "$uid" >/dev/null; then + docker exec -u www-data "$NC_CONTAINER" php occ user:info "$uid" >/dev/null + fi + fi +done +docker exec -u www-data "$NC_CONTAINER" php occ status --output json +if [[ -n "${GITHUB_ENV:-}" ]]; then + for name in NC_BASE_URL NC_CONTAINER NC_TEST_PASSWORD NC_ADMIN_USER NC_RECIPIENT_USER NC_UNRELATED_USER; do + printf '%s=%s\n' "$name" "${!name}" >> "$GITHUB_ENV" + done +fi +printf 'NC36 isolated test lab ready: db=%s libresign=%s\n' "$DB" "${INSTALL_LIBRESIGN:-0}" diff --git a/scripts/ci/php-lint.sh b/scripts/ci/php-lint.sh new file mode 100755 index 0000000..da89456 --- /dev/null +++ b/scripts/ci/php-lint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +set -euo pipefail +cd "$(dirname "$0")/../.." +find lib appinfo templates tests/php -type f -name '*.php' -print0 | xargs -0 -n 1 php -l diff --git a/tests/ci/compose.yml b/tests/ci/compose.yml new file mode 100644 index 0000000..b32fb34 --- /dev/null +++ b/tests/ci/compose.yml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +services: + nextcloud: + image: nextcloud:35-apache + container_name: ${NC_CONTAINER:-communications-ci-nc36} + entrypoint: ["/usr/local/bin/apache2-foreground"] + restart: "no" + ports: + - "127.0.0.1:${NC_PORT:-18884}:80" + volumes: + - ${NC_RUNTIME_DIR:-../../.ci/runtime}/nextcloud:/var/www/html + - ../..:/var/www/html/custom_apps/communications + - ${NC_RUNTIME_DIR:-../../.ci/runtime}/libresign:/var/www/html/custom_apps/libresign:ro + postgres: + image: postgres:17-alpine + profiles: ["postgresql"] + restart: "no" + environment: + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: ${NC_TEST_PASSWORD:?Provide a generated ephemeral lab password} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] + interval: 3s + retries: 40 + mariadb: + image: mariadb:11.8.9 + profiles: ["mariadb"] + restart: "no" + environment: + MYSQL_DATABASE: nextcloud + MYSQL_USER: nextcloud + MYSQL_PASSWORD: ${NC_TEST_PASSWORD:?Provide a generated ephemeral lab password} + MYSQL_ROOT_PASSWORD: ${NC_TEST_PASSWORD:?Provide a generated ephemeral lab password} + healthcheck: + test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"] + interval: 3s + retries: 40 + +networks: + default: + ipam: + config: + - subnet: ${NC_CI_SUBNET:-10.241.42.0/24} diff --git a/tests/e2e/app-smoke.spec.mjs b/tests/e2e/app-smoke.spec.mjs new file mode 100644 index 0000000..90fe3be --- /dev/null +++ b/tests/e2e/app-smoke.spec.mjs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +import { test, expect } from '@playwright/test' +import { randomUUID } from 'node:crypto' + +const USERS = { + admin: process.env.NC_ADMIN_USER || 'qa_admin', + recipient: process.env.NC_RECIPIENT_USER || 'poc_second', + unrelated: process.env.NC_UNRELATED_USER || 'qa_unrelated', +} +const password = process.env.NC_TEST_PASSWORD + +async function signIn(page, uid) { + if (!password) throw new Error('NC_TEST_PASSWORD is required for isolated tests') + await page.goto('/index.php/login') + await page.locator('#user').fill(uid) + await page.locator('#password').fill(password) + await Promise.all([ + page.waitForURL(url => !url.pathname.endsWith('/login'), { timeout: 30000 }), + page.locator('button[type="submit"]').click(), + ]) + await page.goto('/index.php/apps/communications/') + await expect(page.locator('.cc-hub-tabs')).toBeVisible() +} + +async function request(page, method, route, data) { + return page.evaluate(async ({ method, route, data }) => { + const token = window.OC?.requestToken || document.querySelector('[data-requesttoken]')?.getAttribute('data-requesttoken') + const r = await fetch('/index.php/apps/communications' + route, { + method, credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'requesttoken': token || '', ...(data === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(data === undefined ? {} : { body: JSON.stringify(data) }), + }) + return { status: r.status, body: await r.text() } + }, { method, route, data }) +} + +test('publication and audience isolation are persisted by the selected database', async ({ browser }) => { + const contexts = await Promise.all([browser.newContext(), browser.newContext(), browser.newContext()]) + const [admin, recipient, unrelated] = await Promise.all(contexts.map(c => c.newPage())) + let created + try { + await signIn(admin, USERS.admin) + await signIn(recipient, USERS.recipient) + await signIn(unrelated, USERS.unrelated) + const name = `QA DB audience ${randomUUID()}` + const createdResponse = await request(admin, 'POST', '/api/campaigns', { + name, headline: name, message: 'An isolated publication for a test recipient', + trigger_key: 'nextcloud.board.published', presentation: 'none', board_visible: true, + status: 'active', category: 'institutional', audience_type: 'users', + audience_users: [USERS.recipient], placement: 'any', frequency: 'once', + notify_users: false, notify_email: false, notify_activity: false, + priority: 10, starts_at: null, ends_at: null, + }) + expect(createdResponse.status, createdResponse.body.slice(0, 350)).toBe(201) + created = JSON.parse(createdResponse.body).campaign + const allowed = await request(recipient, 'GET', `/api/board/${created.id}`) + expect(allowed.status, allowed.body.slice(0, 350)).toBe(200) + const forbidden = await request(unrelated, 'GET', `/api/board/${created.id}`) + expect(forbidden.status, 'Non-recipient must never see publication').toBe(404) + expect((await request(admin, 'GET', `/api/board/${created.id}`)).status).toBe(404) + await recipient.goto(`/index.php/apps/communications/board?id=${created.id}`) + await expect(recipient.getByText(name).first()).toBeVisible() + } finally { + if (created) { + const archived = await request(admin, 'DELETE', `/api/campaigns/${created.id}`, {}) + expect(archived.status).toBe(200) + const removed = await request(admin, 'POST', `/api/campaigns/${created.id}/purge`, { confirmName: created.name }) + expect(removed.status).toBe(200) + } + await Promise.all(contexts.map(c => c.close())) + } +}) diff --git a/tests/e2e/libresign-event.spec.mjs b/tests/e2e/libresign-event.spec.mjs new file mode 100644 index 0000000..880f837 --- /dev/null +++ b/tests/e2e/libresign-event.spec.mjs @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 LibreCode Coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * Compatibility acceptance for a third-party event source. The application + * contains NO LibreSign-specific adapter: the standard native-event discovery, + * subscriber and delivery paths are tested against a LibreSign fixture. + * + * The fixture represents a real LibreSign *event type* and a synthetic state + * transition; it does not create or sign a genuine PDF. Never dispatch such + * a synthetic event globally, as that could run unrelated LibreSign callbacks. + */ +import { test, expect } from '@playwright/test' +import { execFileSync } from 'node:child_process' +import { randomUUID, createHash } from 'node:crypto' + +const APP_ID = 'communications' +const APP_PATH = `/index.php/apps/${APP_ID}` +const EVENT = 'OCA\\Libresign\\Events\\SigningRequestCompletedEvent' +const EVENT_KEY = 'php_event.' + createHash('sha256').update(EVENT).digest('hex').slice(0, 24) +const CONTAINER = process.env.NC_CONTAINER || 'communications-nc36-qa' +const ADMIN = process.env.NC_ADMIN_USER || 'qa_admin' +const RECIPIENT = process.env.NC_RECIPIENT_USER || 'poc_second' +const UNRELATED = process.env.NC_UNRELATED_USER || 'qa_unrelated' +const PASSWORD = process.env.NC_TEST_PASSWORD + +function requireFixturePassword() { + if (!PASSWORD) throw new Error('NC_TEST_PASSWORD is required for isolated acceptance tests') +} + +async function login(page, user) { + requireFixturePassword() + await page.goto('/index.php/login') + await page.locator('#user').fill(user) + await page.locator('#password').fill(PASSWORD) + await Promise.all([ + page.waitForURL(url => !url.pathname.endsWith('/login'), { timeout: 30000 }), + page.locator('button[type="submit"]').click(), + ]) +} + +async function appApi(page, method, route, body = undefined) { + const token = await page.evaluate(() => window.OC?.requestToken + || document.querySelector('[data-requesttoken]')?.getAttribute('data-requesttoken')) + return page.evaluate(async ({ method, route, body, token }) => { + const response = await fetch(`/index.php/apps/${'communications'}${route}`, { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + requesttoken: token || '', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + return { status: response.status, body: await response.text() } + }, { method, route, body, token }) +} + +function fixturePHP() { + return JSON.parse(execFileSync('docker', [ + 'exec', '-u', 'www-data', CONTAINER, 'php', + `/var/www/html/custom_apps/${APP_ID}/tests/manual/mvp_libresign_native_event_v29.php`, + ], { encoding: 'utf8', timeout: 60000 })) +} + +function runCron() { + execFileSync('docker', ['exec', '-u', 'www-data', CONTAINER, + 'php', '/var/www/html/cron.php'], { encoding: 'utf8', timeout: 75000 }) +} + +test('LibreSign completion is discovered and delivered through generic native-event flow', async ({ browser }) => { + const adminContext = await browser.newContext() + const admin = await adminContext.newPage() + const recipientContext = await browser.newContext() + const recipient = await recipientContext.newPage() + const unrelatedContext = await browser.newContext() + const unrelated = await unrelatedContext.newPage() + let publication + + try { + await login(admin, ADMIN) + await admin.goto(`${APP_PATH}/`) + await expect(admin.locator('.cc-hub-tabs')).toBeVisible() + + const catalogResponse = await appApi(admin, 'GET', '/api/catalog') + expect(catalogResponse.status).toBe(200) + const catalog = JSON.parse(catalogResponse.body) + const source = catalog.automation.applications.find(app => app.id === 'libresign') + expect(source, 'LibreSign app discovered without a built-in adapter').toBeDefined() + expect(source.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ eventClass: EVENT, key: EVENT_KEY }), + ])) + expect(source.actions.some(action => action.key === 'libresign.signer.signed')).toBe(false) + + // Exercise the real UI: the event must be selectable using the same + // application/event controls as any other enabled Nextcloud app. + await admin.locator('.cc-hub-tabs button').filter({ hasText: 'Publicações' }).click() + await admin.locator('.cc-hub-top button').filter({ hasText: 'Nova publicação' }).click() + await admin.locator('.cc-automation-intent-card').filter({ hasText: 'Em um aplicativo' }).click() + await admin.locator('#cc-app-source').click() + await admin.getByRole('option').filter({ hasText: 'LibreSign' }).first().click() + await expect(admin.locator('#cc-app-event')).toBeVisible() + await admin.locator('#cc-app-event').click() + await expect(admin.getByRole('option').filter({ hasText: 'SigningRequestCompletedEvent' }).first()).toBeVisible() + await admin.getByRole('option').filter({ hasText: 'SigningRequestCompletedEvent' }).first().click() + await expect(admin.locator('.cc-automation')).toContainText('SigningRequestCompletedEvent') + + const name = `QA LibreSign event ${randomUUID()}` + const created = await appApi(admin, 'POST', '/api/campaigns', { + name, headline: name, message: 'A signed document workflow has completed', + trigger_key: EVENT_KEY, placement: 'libresign', status: 'active', + presentation: 'none', board_visible: true, notify_email: false, + notify_users: false, notify_activity: false, gateway_channels: [], + audience_type: 'users', audience_users: [RECIPIENT], + category: 'institutional', frequency: 'event', priority: 10, + starts_at: null, ends_at: null, + }) + expect(created.status, created.body.slice(0, 600)).toBe(201) + publication = JSON.parse(created.body).campaign + + await login(recipient, RECIPIENT) + await recipient.goto(`${APP_PATH}/`) + const before = await appApi(recipient, 'GET', `/api/board/${publication.id}`) + expect(before.status, 'Event-only publication must not leak before trigger').toBe(404) + + const event = fixturePHP() + expect(event.trigger).toBe(EVENT_KEY) + expect(event.user).toBe(RECIPIENT) + expect(event.context).toBe('libresign') + expect(event.webhookContract.status).toBe('completed') + expect(JSON.stringify(event.webhookContract)).not.toContain(RECIPIENT) + runCron() + + const received = await appApi(recipient, 'GET', `/api/board/${publication.id}`) + expect(received.status, received.body.slice(0, 450)).toBe(200) + await recipient.goto(`${APP_PATH}/board?id=${publication.id}`) + await expect(recipient.getByText(name).first()).toBeVisible() + + await login(unrelated, UNRELATED) + await unrelated.goto(`${APP_PATH}/`) + const forbidden = await appApi(unrelated, 'GET', `/api/board/${publication.id}`) + expect(forbidden.status, 'Unrelated user cannot read private event').toBe(404) + } finally { + if (publication) { + const archived = await appApi(admin, 'DELETE', `/api/campaigns/${publication.id}`, {}) + expect(archived.status).toBe(200) + const purged = await appApi(admin, 'POST', `/api/campaigns/${publication.id}/purge`, + { confirmName: publication.name }) + expect(purged.status).toBe(200) + } + await Promise.all([adminContext.close(), recipientContext.close(), unrelatedContext.close()]) + } +}) diff --git a/tests/manual/mvp_event_probe_v29.php b/tests/manual/mvp_event_probe_v29.php new file mode 100644 index 0000000..01e850b --- /dev/null +++ b/tests/manual/mvp_event_probe_v29.php @@ -0,0 +1,86 @@ +get(CampaignRepository::class); +$c=$repo->find($id); +fwrite(STDERR,'PROBE_FIND '.json_encode($c===null).'\n'); +if (!$c || !str_starts_with($c['name'],'MVP Real v29 ')) { + throw new \InvalidArgumentException('Refusing to inspect non-fixture campaign'); +} +fwrite(STDERR,'PROBE_AFTER_CHECK\n'); +$db=$server->get(\OCP\IDBConnection::class); +$qb=$db->getQueryBuilder(); +$rs=$qb->select('id','event_id','uid','channel','status','attempts','last_error','version','sent_at') + ->from('cc_board_sent') + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->orderBy('id','ASC')->executeQuery(); +$rows=$rs->fetchAllAssociative();$rs->closeCursor(); +$eventId=null; +$q=$db->getQueryBuilder(); +$r=$q->select('id')->from('cc_events') + ->where($q->expr()->eq('uid',$q->createNamedParameter('poc_second'))) + ->andWhere($q->expr()->eq('trigger_key',$q->createNamedParameter($c['trigger_key']))) + ->andWhere($q->expr()->eq('context_app',$q->createNamedParameter('campaign_'.$id))) + ->orderBy('id','DESC')->setMaxResults(1)->executeQuery(); +$value=$r->fetchOne();$r->closeCursor(); +if ($value!==false) {$eventId=(int)$value;} +$jobs=[]; +$q=$db->getQueryBuilder(); +$r=$q->select('id','class','argument')->from('jobs') + ->where($q->expr()->like('class',$q->createNamedParameter('%EventChannelDeliveryJob%'))) + ->executeQuery(); +foreach ($r->fetchAllAssociative() as $job) { + $decoded=json_decode($job['argument'],true); + if (is_array($decoded) && is_int($decoded['eventId']??null)) { + $jobs[]=['jobId'=>(int)$job['id'],'eventId'=>$decoded['eventId']]; + } +} +$r->closeCursor(); +$records=[]; +foreach (['notifications','activity'] as $table) { + $q=$db->getQueryBuilder(); + $rs=$q->select('*')->from($table) + ->where($q->expr()->eq('app',$q->createNamedParameter('communications'))) + ->setMaxResults(100)->executeQuery(); + $rowsForApp=$rs->fetchAllAssociative();$rs->closeCursor(); + foreach ($rowsForApp as $entry) { + $objectId=$entry['object_id']??$entry['objectid']??null; + if ((string)$objectId===(string)$id) { + $records[$table][]=[ + 'uid'=>$entry['user']??$entry['affecteduser']??$entry['affected_user']??null, + 'type'=>$entry['object_type']??$entry['type']??null, + 'id'=>(int)$entry['id'], + ]; + } + } +} +$board=$server->get(\OCA\Communications\Service\BoardService::class); +$inbox=$board->list('poc_second',1,$c['headline'],'all'); +$view=$board->view('poc_second',$id); +$inboxFound=false;foreach($inbox['announcements'] as $item){if($item['id']===$id){$inboxFound=true;break;}} +echo json_encode([ + 'campaignId'=>$id,'eventId'=>$eventId,'publishedAt'=>(int)$c['published_at'], + 'outbox'=>$rows,'jobs'=>$jobs,'records'=>$records, + 'inboxFound'=>$inboxFound, + 'viewAllowed'=>$view!==null, +],JSON_THROW_ON_ERROR); + +} catch (\Throwable $error) { + fwrite(STDERR,"PROBE_EXCEPTION ".get_class($error)." ".$error->getMessage()."\n"); + exit(2); +} diff --git a/tests/manual/mvp_gateway_event_v29.php b/tests/manual/mvp_gateway_event_v29.php new file mode 100644 index 0000000..dcd3cab --- /dev/null +++ b/tests/manual/mvp_gateway_event_v29.php @@ -0,0 +1,43 @@ +messages[]=[ + 'gateway'=>$gateway, + 'destination'=>$destination, + 'linked'=>str_contains($body,'/apps/communications/board?id='), + ]; + } +} +$id=(int)($argv[1]??0); +$eventId=(int)($argv[2]??0); +$c=\OC::$server->get(\OCA\Communications\Service\CampaignRepository::class)->find($id); +$event=\OC::$server->get(\OCA\Communications\Service\EventStore::class)->byId($eventId); +if (!$c || !$event || !str_starts_with($c['name'],'MVP Gateway v29 ') + || $c['trigger_key']!=='external.webhook' + || $event['context_app']!=='campaign_'.$id || $event['uid']!=='poc_second') { + throw new \InvalidArgumentException('Refusing to invoke non-isolated recipient event'); +} +$publisher=\OC::$server->get(BoardPublicationService::class); +$stub=new MVPGatewayStubV29(); +(new ReflectionProperty(BoardPublicationService::class,'gatewayTransport'))->setValue($publisher,$stub); +$first=$publisher->dispatchEvent($eventId); +$second=$publisher->dispatchEvent($eventId); +$db=\OC::$server->get(\OCP\IDBConnection::class); +$q=$db->getQueryBuilder(); +$rs=$q->select('event_id','uid','channel','status','attempts')->from('cc_board_sent') + ->where($q->expr()->eq('campaign_id',$q->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->executeQuery(); +$rows=$rs->fetchAllAssociative();$rs->closeCursor(); +echo json_encode(['messages'=>$stub->messages,'rows'=>$rows,'first'=>$first,'second'=>$second],JSON_THROW_ON_ERROR); diff --git a/tests/manual/mvp_libresign_native_event_v29.php b/tests/manual/mvp_libresign_native_event_v29.php new file mode 100644 index 0000000..55c74f5 --- /dev/null +++ b/tests/manual/mvp_libresign_native_event_v29.php @@ -0,0 +1,43 @@ +get(\OCP\IUserManager::class)->get($uid); +if ($user===null || !$user->isEnabled()) { + throw new \RuntimeException('Missing isolated lab subject'); +} +$session=$server->get(\OCP\IUserSession::class); +$session->setUser($user); +$events=$server->get(\OCA\Communications\Service\EventStore::class); +$trigger=\OCA\Communications\Service\NativeEventCatalog::triggerKey( + \OCA\Libresign\Events\SigningRequestCompletedEvent::class +); +$before=array_column($events->forUser($uid,time()-60,250),'id'); +$file=new \OCA\Libresign\Db\File(); +$file->setUuid('mvp-native-'.bin2hex(random_bytes(12))); +$file->setNodeType('file'); +$file->setStatus(\OCA\Libresign\Enum\FileStatus::SIGNED->value); +$event=\OCA\Libresign\Events\SigningRequestCompletedEvent::fromTransition( + $file,\OCA\Libresign\Enum\FileStatus::PARTIAL_SIGNED->value +); +if ($event===null) {throw new \RuntimeException('No aggregate completion event');} +$listener=$server->get(\OCA\Communications\AppInfo\NativeEventListener::class); +$listener->handle($event); +$after=array_values(array_filter($events->forUser($uid,time()-60,250), + static fn(array $row):bool=>!in_array($row['id'],$before,true))); +if(count($after)!==1 || $after[0]['trigger_key']!==$trigger || + $after[0]['context_app']!=='libresign' || $after[0]['uid']!==$uid) { + throw new \RuntimeException('Native event not recorded or wrong subject'); +} +echo json_encode(['eventId'=>$after[0]['id'], + 'trigger'=>$trigger,'user'=>$after[0]['uid'], + 'context'=>$after[0]['context_app'], + 'webhookContract'=>$event->getWebhookSerializable(), +],JSON_THROW_ON_ERROR); diff --git a/tests/manual/test_channel_only_dispatch_v27.php b/tests/manual/test_channel_only_dispatch_v27.php new file mode 100644 index 0000000..a38872e --- /dev/null +++ b/tests/manual/test_channel_only_dispatch_v27.php @@ -0,0 +1,130 @@ +messages[]=[ + 'subject'=>$message->getSubject(), + 'body'=>$message->getPlainBody(), + 'recipientCount'=>count($message->getTo()), + ]; + if ($this->simulateAmbiguousSubmission) { + throw new \RuntimeException('Synthetic timeout after SMTP DATA'); + } + return $this->forceFailure ? ['undeliverable'] : []; + } +} +class LabAppManagerV27 extends \OC\App\AppManager { + public function __construct() {} + public function isEnabledForUser($appId,$user=null) { + return in_array($appId,['notifications','activity'],true); + } +} +class LabNotificationsV27 extends \OC\Notification\Manager { + public array $messages=[]; + public function __construct(private BoardNotifier $renderer,private \OCP\Notification\IManager $real) {} + public function createNotification():INotification { + return $this->real->createNotification(); + } + public function notify(INotification $notification):void { + // Simulate synchronous rendering while the outbox row is still claimed. + $rendered=$this->renderer->prepare($notification,'pt_BR'); + $this->messages[]=[ + 'uid'=>$notification->getUser(), + 'subject'=>$rendered->getParsedSubject(), + 'link'=>$rendered->getLink(), + ]; + } +} +class LabGatewayV27 extends GatewayTransport { + public array $messages=[]; + public function __construct() {} + public function send(string $gateway,string $destination,string $body):void { + $this->messages[]=['provider'=>$gateway,'destination'=>$destination,'body'=>$body]; + } +} + +$id=(int)($argv[1]??0); +$version=(int)($argv[2]??0); +if ($id<=0 || $version<=0) { throw new \InvalidArgumentException('Fixture campaign and version required'); } +$server=\OC::$server; +$campaign=$server->get(\OCA\Communications\Service\CampaignRepository::class)->find($id); +if (!$campaign || (int)$campaign['published_at']!==$version || $campaign['board_visible'] + || $campaign['presentation']!=='none' || !str_starts_with($campaign['name'],'OnlyChannel v27 ')) { + throw new \InvalidArgumentException('Refuse non-fixture or non-channel-only campaign'); +} +$board=$server->get(BoardService::class); +$before=$board->view('poc_second',$id)!==null; +$mail=new LabMailerV27(); +$mail->forceFailure=($argv[3]??'')==='smtp-failed'; +$mail->simulateAmbiguousSubmission=($argv[3]??'')==='smtp-unknown'; +$notify=new LabNotificationsV27( + $server->get(BoardNotifier::class), + $server->get(\OCP\Notification\IManager::class) +); +$gateway=new LabGatewayV27(); +$publisher=$server->get(BoardPublicationService::class); +(new ReflectionProperty(BoardPublicationService::class,'mailer'))->setValue($publisher,$mail); +(new ReflectionProperty(BoardPublicationService::class,'notifications'))->setValue($publisher,$notify); +(new ReflectionProperty(BoardPublicationService::class,'apps'))->setValue($publisher,new LabAppManagerV27()); +(new ReflectionProperty(BoardPublicationService::class,'gatewayTransport'))->setValue($publisher,$gateway); + +$user=$server->get(\OCP\IUserManager::class)->get('poc_second'); +if (!$user) {throw new \RuntimeException('Missing test user');} +$original=$user->getEMailAddress(); +if (!filter_var($original,FILTER_VALIDATE_EMAIL)) { + $user->setEMailAddress('poc-second@example.test'); +} +try { + $due=(new ReflectionMethod(BoardPublicationService::class,'due'))->invoke($publisher,$campaign,time()); + (new ReflectionMethod(BoardPublicationService::class,'ensurePublication'))->invoke($publisher,$id,$version); + $first=$publisher->broadcast($id,$version); + $second=$publisher->broadcast($id,$version); + $after=$board->view('poc_second',$id); + $blocked=$board->view('poc_admin',$id); + $inbox=$board->list('poc_second',1,'OnlyChannel v27', 'all'); + $read=$after!==null ? $board->markRead('poc_second',$id) : false; + $db=$server->get(\OCP\IDBConnection::class); + $qb=$db->getQueryBuilder(); + $rs=$qb->select('uid','channel','status','attempts','last_error')->from('cc_board_sent') + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $rows=$rs->fetchAllAssociative();$rs->closeCursor(); + echo json_encode([ + 'due'=>$due,'beforeView'=>$before,'afterView'=>$after!==null, + 'afterSource'=>$after['source']??null, + 'outsiderCanView'=>$blocked!==null,'inboxTotal'=>$inbox['total'], + 'detailMarkRead'=>$read,'mail'=>$mail->messages, + 'notify'=>$notify->messages,'gateway'=>$gateway->messages, + 'rows'=>$rows,'first'=>$first,'second'=>$second, + ],JSON_THROW_ON_ERROR); +} finally { + if (!filter_var($original,FILTER_VALIDATE_EMAIL)) { + $user->setEMailAddress(is_string($original)?$original:''); + } +} diff --git a/tests/manual/test_flow_v08.php b/tests/manual/test_flow_v08.php new file mode 100644 index 0000000..afea671 --- /dev/null +++ b/tests/manual/test_flow_v08.php @@ -0,0 +1,81 @@ +get(CampaignRepository::class); +$manager = $container->get(Manager::class); +$scope = new ScopeContext(IManager::SCOPE_ADMIN); +if ($cmd === 'create') { + $campaign = $repo->create([ + 'name'=>'Flow verified '.bin2hex(random_bytes(4)), + 'description'=>'Temporary end-to-end test of matching Flow conditions', + 'headline'=>'Real Flow matched', 'message'=>'Flow match confirmed', + 'cta_label'=>'', 'cta_url'=>'', 'trigger_key'=>'nextcloud.flow.matched', + 'audience_type'=>'users', 'audience_users'=>['poc_admin'], 'audience_groups'=>[], + 'audience_group'=>'', 'presentation'=>'banner', + 'status'=>'active','priority'=>99, 'starts_at'=>null,'ends_at'=>null, + 'frequency'=>'once', 'placement'=>'files', + ], 'poc_admin'); + $id = (int)$campaign['id']; + $alternative = $repo->create([ + ...$campaign, + 'name'=>'Flow verified alternative '.bin2hex(random_bytes(4)), + 'headline'=>'Flow switched successfully', + ], 'poc_admin'); + $alternativeId = (int)$alternative['id']; + $rule = $manager->addOperation( + FlowCampaignOperation::class, + 'Flow matching text files #'.$id, + [['class'=>FileMimeType::class, 'operator'=>'is','value'=>'text/plain']], + (string)$id, + $scope, + FileEntity::class, + ['\OCP\Files::postCreate'] + ); + $stateData = ['campaignId'=>$id,'alternativeId'=>$alternativeId,'flowRuleId'=>(int)$rule['id']]; + file_put_contents($state,json_encode($stateData,JSON_THROW_ON_ERROR)); + echo 'FLOW_CREATED '.json_encode($stateData)."\n"; +} elseif ($cmd === 'verify') { + $d=json_decode(file_get_contents($state),true); + $id=(int)($argv[2] ?? $d['campaignId']); + $store=$container->get(EventStore::class); + $events=$store->forUser('poc_admin',time()-3600,300); + $match=array_values(array_filter($events,static fn(array $e):bool => + $e['trigger_key']==='nextcloud.flow.matched' && $e['context_app']==='campaign_'.$id)); + $delivery=$container->get(DeliveryStore::class); + $delivery->prepare('poc_admin','files',false); + $pending=$delivery->pending('poc_admin','files'); + $seen=array_values(array_filter($pending,static fn(array $p):bool => $p['campaignId']===$id)); + echo 'FLOW_VERIFIED '.json_encode(['eventCount'=>count($match),'deliveryCount'=>count($seen)])."\n"; + exit(count($match)>0 && count($seen)>0 ? 0:2); +} elseif ($cmd === 'cleanup') { + if (is_file($state)) { + $d=json_decode(file_get_contents($state),true); + $manager->deleteOperation((int)$d['flowRuleId'],$scope); + $repo->setStatus((int)$d['campaignId'],'archived'); + if (!empty($d['alternativeId'])) { $repo->setStatus((int)$d['alternativeId'],'archived'); } + unlink($state); + echo 'FLOW_CLEANED_UP'."\n"; + } +} else { fwrite(STDERR,'Usage: create|verify|cleanup'."\n");exit(1); } diff --git a/tests/manual/test_gateway_dispatch_v26.php b/tests/manual/test_gateway_dispatch_v26.php new file mode 100644 index 0000000..dc55037 --- /dev/null +++ b/tests/manual/test_gateway_dispatch_v26.php @@ -0,0 +1,40 @@ +messages[]=['gateway'=>$gateway,'destination'=>$destination, + 'hasLink'=>str_contains($body,'/apps/communications/board')]; + } +} +$id=(int)($argv[1]??0); +$version=(int)($argv[2]??0); +if ($id<=0 || $version<=0) { throw new RuntimeException('Fixture requires test campaign and version'); } +$server=\OC::$server; +$store=$server->get(BoardPublicationService::class); +$fake=new GatewayTransportStubV26(); +(new ReflectionProperty(BoardPublicationService::class,'gatewayTransport'))->setValue($store,$fake); +(new ReflectionMethod(BoardPublicationService::class,'ensurePublication'))->invoke($store,$id,$version); +$first=$store->broadcast($id,$version); +$second=$store->broadcast($id,$version); + +$db=$server->get(\OCP\IDBConnection::class); +$qb=$db->getQueryBuilder(); +$rs=$qb->select('uid','channel','status','attempts','last_error')->from('cc_board_sent') + ->where($qb->expr()->eq('campaign_id',$qb->createNamedParameter($id,IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version',$qb->createNamedParameter($version,IQueryBuilder::PARAM_INT))) + ->executeQuery(); +$rows=$rs->fetchAllAssociative();$rs->closeCursor(); +echo json_encode(['messages'=>$fake->messages, 'rows'=>$rows, 'first'=>$first,'second'=>$second], + JSON_THROW_ON_ERROR); diff --git a/tests/manual/test_gateway_transport_contract_v26.php b/tests/manual/test_gateway_transport_contract_v26.php new file mode 100644 index 0000000..de563b4 --- /dev/null +++ b/tests/manual/test_gateway_transport_contract_v26.php @@ -0,0 +1,59 @@ +calls[]=['ensure',$id];} + public function isGatewayComplete(string $id):bool { + $this->calls[]=['complete',$id];return $id==='telegram'; + } + public function send(string $id,string $dest,string $body):void { + $this->calls[]=['send',$id,$dest,$body]; + } +} +class FakeContainerV26 implements ContainerInterface { + public function __construct(private object $service) {} + public function get(string $id):mixed { + if($id!=='OCA\TwoFactorGateway\Service\GatewayDirectIntegrationService') { + throw new RuntimeException('Unrecognized direct service'); + } + return $this->service; + } + public function has(string $id):bool {return true;} +} +class EnabledGatewayV26 extends GatewayTransport { + public function installed():bool{return true;} +} +$fake=new FakeDirectGatewayV26(); +$transport=new EnabledGatewayV26( + new FakeContainerV26($fake), + \OC::$server->get(\OCP\App\IAppManager::class), + \OC::$server->get(\Psr\Log\LoggerInterface::class) +); +if (!$transport->configured('telegram') || $transport->configured('signal')) { + throw new RuntimeException('Provider completeness check failed'); +} +$transport->send('telegram','123456789','Aviso de laboratório'); +$called=array_values(array_filter($fake->calls, static fn(array $c):bool=>$c[0]==='send')); +if ($called!==[['send','telegram','123456789','Aviso de laboratório']]) { + throw new RuntimeException('Unexpected direct send contract'); +} +$invalid=false; +try { $transport->send('telegram','-100123','Invalid destination'); } +catch (InvalidArgumentException) {$invalid=true;} +if (!$invalid) {throw new RuntimeException('Unsafe destination passed');} +$notReady=false; +try { $transport->send('signal','+15555550123','Blocked transport'); } +catch (RuntimeException) {$notReady=true;} +if (!$notReady) {throw new RuntimeException('Unconfigured gateway sent a message');} +echo json_encode(['providerCompleteness'=>true,'sendContract'=>true, + 'invalidRecipientRejected'=>true,'unconfiguredProviderRejected'=>true, + 'calls'=>$fake->calls],JSON_THROW_ON_ERROR); diff --git a/tests/manual/test_immediate_visual_v28.php b/tests/manual/test_immediate_visual_v28.php new file mode 100644 index 0000000..5388255 --- /dev/null +++ b/tests/manual/test_immediate_visual_v28.php @@ -0,0 +1,34 @@ +get(CampaignRepository::class)->find($id); +if (!$c || !str_starts_with($c['name'],'Unified V28 ') + || $c['trigger_key']!=='nextcloud.board.published' + || !in_array($c['presentation'],['banner','modal'],true)) { + throw new \InvalidArgumentException('Only isolated immediate visual fixtures allowed'); +} +$store=\OC::$server->get(DeliveryStore::class); +$store->prepare($uid,'files',true); +$one=array_values(array_filter($store->pending($uid,'files',100), + static fn(array $item):bool=>$item['campaignId']===$id)); +$store->prepare($uid,'files',true); +$two=array_values(array_filter($store->pending($uid,'files',100), + static fn(array $item):bool=>$item['campaignId']===$id)); +echo json_encode([ + 'id'=>$id, 'presentation'=>$c['presentation'], + 'first'=>count($one),'second'=>count($two), + 'stable'=>$one!==[] && $two!==[] + && $one[0]['deliveryId']===$two[0]['deliveryId'], + 'title'=>$one[0]['title']??null, + 'inbox'=>$c['board_visible'],'email'=>$c['notify_email'], +],JSON_THROW_ON_ERROR); diff --git a/tests/manual/test_page_routes_v11.php b/tests/manual/test_page_routes_v11.php new file mode 100644 index 0000000..a694fec --- /dev/null +++ b/tests/manual/test_page_routes_v11.php @@ -0,0 +1,86 @@ +get(WebRouteCatalog::class); +$repo = $server->get(CampaignRepository::class); +$deliveries = $server->get(DeliveryStore::class); +$events = $server->get(EventStore::class); +$uid = 'poc_second'; + +function check(bool $ok,string $message): void { + echo ($ok?'PASS':'FAIL') . ' | ' . $message . "\n"; + if (!$ok) { throw new RuntimeException($message); } +} +check(count($catalog->routesFor('dashboard'))>0,'Dashboard discovered from FrontpageRoute attribute'); +$files=$catalog->routesFor('files'); +check(count($files)>=2,'Files has distinct browser routes'); +check(!in_array('/apps/files/api/v1/stats',array_column($files,'value'),true),'API route not exposed'); +check($catalog->matches('/apps/files/{view}','files','/index.php/apps/files/recent'), 'route placeholder recognized'); +check(!$catalog->matches('/apps/files/{view}','files','/index.php/apps/files/api/v1/stats'), 'API page rejected'); + +$data=[ + 'name'=>'Route matching proof '.bin2hex(random_bytes(4)), + 'description'=>'Automated test, deleted immediately', + 'headline'=>'Only on a specific web page', + 'message'=>'Tested route routing', + 'cta_label'=>'','cta_url'=>'', + 'trigger_key'=>'nextcloud.page.opened', + 'page_route'=>'/apps/files/{view}', + 'placement'=>'files', + 'audience_type'=>'users','audience_users'=>[$uid], + 'audience_groups'=>[],'audience_group'=>'','excluded_groups'=>[], + 'presentation'=>'banner','status'=>'active','priority'=>100, + 'starts_at'=>null,'ends_at'=>null,'frequency'=>'event', + 'max_total'=>0,'max_per_day'=>0, +]; +try { + $invalid=$data; + $invalid['page_route']='/apps/files/api/v1/stats'; + $thrown=false; + try {$repo->validate($invalid);}catch(InvalidArgumentException){$thrown=true;} + check($thrown,'Backend refuses an API endpoint as campaign route'); + $campaign=$repo->create($data,'poc_admin'); + $id=$campaign['id']; + try { + check($campaign['page_route']==='/apps/files/{view}','Saved route pattern without actual URL'); + $eventFor=static fn():array => array_values(array_filter( + $events->forUser($uid,time()-60,300), + static fn(array $event):bool => $event['trigger_key']==='nextcloud.page.opened' + && $event['context_app']==='campaign_'.$id, + )); + $deliveries->prepare($uid,'files',true,'/index.php/apps/files/api/v1/stats'); + check(count($eventFor())===0,'API call does not generate a page event'); + $deliveries->prepare($uid,'dashboard',true,'/index.php/apps/dashboard/'); + check(count($eventFor())===0,'Opening Dashboard does not trigger Files route'); + $deliveries->prepare($uid,'files',true,'/index.php/apps/files/'); + check(count($eventFor())===0,'Files root does not match files/{view}'); + $deliveries->prepare($uid,'files',true,'/index.php/apps/files/recent'); + check(count($eventFor())===1,'Actual page visit creates scoped event for selected campaign only'); + $pending=$deliveries->pending($uid,'files',250,'/index.php/apps/files/recent'); + check(count(array_filter($pending,static fn(array $item):bool=>$item['campaignId']===$id))===1, + 'Only matching route creates a campaign delivery'); + $pendingWrong=$deliveries->pending($uid,'files',250,'/index.php/apps/files/'); + check(count(array_filter($pendingWrong,static fn(array $item):bool=>$item['campaignId']===$id))===0, + 'Pending campaign cannot be shown on other routes'); + $subject=$eventFor()[0]; + check($subject['context_app']==='campaign_'.$id,'Event contains no path or private file ID'); + echo 'PAGE_ROUTE_E2E PASSED'."\n"; + }finally{ + $repo->setStatus((int)$id,'archived'); + echo 'CLEANUP archived campaign '.$id."\n"; + } +} catch(Throwable $e) { + fwrite(STDERR, 'PAGE_ROUTE_E2E FAILED: '.$e->getMessage()."\n"); + throw $e; +} diff --git a/tests/manual/test_webhook_scope_v11.php b/tests/manual/test_webhook_scope_v11.php new file mode 100644 index 0000000..b1b6be0 --- /dev/null +++ b/tests/manual/test_webhook_scope_v11.php @@ -0,0 +1,57 @@ +get(CampaignRepository::class); +$events = \OC::$server->get(EventStore::class); +$store = \OC::$server->get(DeliveryStore::class); +$stateFile = '/tmp/cc-scoped-webhook-test.json'; +$cmd = $argv[1] ?? ''; +if ($cmd === 'create') { + $data = [ + 'name'=>'Webhook targeting proof '.bin2hex(random_bytes(3)), + 'description'=>'Ephemeral API target validation test', + 'headline'=>'Webhook targeted only this campaign', + 'message'=>'Signed webhook integration example', + 'cta_label'=>'','cta_url'=>'', + 'trigger_key'=>'external.webhook', 'status'=>'active', 'placement'=>'any', + 'audience_type'=>'users','audience_users'=>['poc_second'], + 'audience_group'=>'','audience_groups'=>[], + 'presentation'=>'banner','priority'=>100, + ]; + $a = $repo->create($data,'poc_admin'); + $data['name']='Webhook unrelated '.bin2hex(random_bytes(3)); + $data['headline']='Must not be triggered'; + $b = $repo->create($data,'poc_admin'); + $state=['a'=>$a['id'],'b'=>$b['id']]; + file_put_contents($stateFile,json_encode($state,JSON_THROW_ON_ERROR)); + echo 'WEBHOOK_TEST_IDS '.json_encode($state)."\n"; +} elseif ($cmd === 'verify') { + $ids=json_decode(file_get_contents($stateFile),true,512,JSON_THROW_ON_ERROR); + $contextA='campaign_'.$ids['a']; $contextB='campaign_'.$ids['b']; + $recent=$events->forUser('poc_second',time()-120,300); + $aEvents=array_filter($recent,static fn($event):bool=>$event['trigger_key']==='external.webhook' && $event['context_app']===$contextA); + $bEvents=array_filter($recent,static fn($event):bool=>$event['trigger_key']==='external.webhook' && $event['context_app']===$contextB); + $store->prepare('poc_second','files',false); + $pending=$store->pending('poc_second','files',250); + $aPending=array_filter($pending,static fn($item):bool=>$item['campaignId']===$ids['a']); + $bPending=array_filter($pending,static fn($item):bool=>$item['campaignId']===$ids['b']); + $ok=count($aEvents)===1 && count($bEvents)===0 && count($aPending)===1 && count($bPending)===0; + echo ($ok?'PASS':'FAIL').' | Signed webhook scoped to one UID and one campaign | '.json_encode([ + 'targetEvents'=>count($aEvents), 'otherEvents'=>count($bEvents), + 'targetDeliveries'=>count($aPending), 'otherDeliveries'=>count($bPending) + ])."\n"; + exit($ok?0:2); +} elseif ($cmd === 'cleanup') { + if(is_file($stateFile)){ + $ids=json_decode(file_get_contents($stateFile),true,512,JSON_THROW_ON_ERROR); + $repo->setStatus((int)$ids['a'],'archived'); + $repo->setStatus((int)$ids['b'],'archived'); + unlink($stateFile); + echo 'WEBHOOK_TEST_CLEANUP completed'."\n"; + } +} else {throw new RuntimeException('Usage create|verify|cleanup');} diff --git a/tests/php/Unit/CtaUrlTest.php b/tests/php/Unit/CtaUrlTest.php new file mode 100644 index 0000000..19f4a31 --- /dev/null +++ b/tests/php/Unit/CtaUrlTest.php @@ -0,0 +1,42 @@ + ['/apps/files/']; + yield 'secure URL' => ['https://example.test/doc?id=5']; + yield 'plain URL' => ['http://example.test/']; + yield 'email' => ['mailto:help@example.test']; + yield 'phone' => ['tel:+5521999999999']; + } + + #[DataProvider('allowed')] + public function testSafeLinksAreAccepted(string $url): void { + self::assertTrue(CtaUrl::isAllowed($url)); + } + + public static function rejected(): iterable { + yield 'empty' => ['']; + yield 'javascript' => ['javascript:alert(1)']; + yield 'data' => ['data:text/html,hi']; + yield 'root-relative double slash' => ['//evil.test/path']; + yield 'encoded credentials' => ['https://user:pass@example.test/']; + yield 'backslash' => ["https:\\\\evil.test"]; + yield 'control char' => ["https://example.test/\nfoo"]; + yield 'angle bracket' => ['https://example.test/ @@ -49,26 +51,26 @@ onUnmounted(()=>window.removeEventListener('popstate',applyHistory))
-

Comunicações

-

Envie e acompanhe avisos, comunicados e mensagens no Nextcloud.

+

{{ t('communications', 'Communications') }}

+

{{ t('communications', 'Send and manage announcements and messages in Nextcloud.') }}

- +
-