diff --git a/.docker/apache/Dockerfile b/.docker/apache/Dockerfile index 7615f69b8e..df9e99722c 100644 --- a/.docker/apache/Dockerfile +++ b/.docker/apache/Dockerfile @@ -1,12 +1,12 @@ # -# This image uses a php:8.4-apache base image and do not have any phpMyFAQ code with it. +# This image uses a php:8.5-apache base image and do not have any phpMyFAQ code with it. # It's for development only, it's meant to be run with docker-compose # ##################################### #=== Unique stage without payload === ##################################### -FROM php:8.4-apache +FROM php:8.5-apache #=== Install gd PHP dependencie === RUN set -x \ @@ -61,6 +61,10 @@ RUN set -ex \ RUN pecl install xdebug-3.5.0 \ && docker-php-ext-enable xdebug +#=== Install redis PHP extension === +RUN pecl install redis-6.3.0 \ + && docker-php-ext-enable redis + #=== php default === ENV PMF_TIMEZONE="Europe/Berlin" \ PMF_ENABLE_UPLOADS=On \ diff --git a/.docker/frankenphp/Caddyfile b/.docker/frankenphp/Caddyfile index c587dc4743..b2c5689d59 100644 --- a/.docker/frankenphp/Caddyfile +++ b/.docker/frankenphp/Caddyfile @@ -15,7 +15,7 @@ # Enable compression encode gzip - # PHP Handler für FrankenPHP + # PHP Handler for FrankenPHP php_server # Exclude assets from being handled by router @@ -24,104 +24,35 @@ # Error pages handle_errors 404 { - rewrite * /index.php?action=404 + rewrite * /404.html php_server } - # General pages - rewrite /add-faq.html /index.php?action=add - rewrite /add-question.html /index.php?action=ask - rewrite /show-categories.html /index.php?action=show - rewrite /search.html /index.php?action=search - rewrite /open-questions.html /index.php?action=open-questions - rewrite /contact.html /index.php?action=contact - rewrite /glossary.html /index.php?action=glossary - rewrite /overview.html /index.php?action=overview - rewrite /login.html /index.php?action=login - rewrite /privacy.html /index.php?action=privacy - rewrite /login /index.php?action=login - rewrite /index.html /index.php - rewrite /bookmarks.html /index.php?action=bookmarks - rewrite /forgot-password /index.php?action=password - - # Solution ID pages - @solution_id path_regexp solution ^/solution_id_([0-9]+)\.html$ - rewrite @solution_id /index.php?solution_id={re.solution.1} - - # FAQ record pages - @faq path_regexp faq ^/content/([0-9]+)/([0-9]+)/([a-z\-_]+)/(.+)\.html?$ - rewrite @faq /index.php?action=faq&cat={re.faq.1}&id={re.faq.2}&artlang={re.faq.3} - - # Category pages with page count - @category_page path_regexp cat_page ^/category/([0-9]+)/([0-9]+)/(.+)\.html?$ - rewrite @category_page /index.php?action=show&cat={re.cat_page.1}&seite={re.cat_page.2} - - # Category pages - @category path_regexp cat ^/category/([0-9]+)/(.+)\.html?$ - rewrite @category /index.php?action=show&cat={re.cat.1} - - # News pages - @news path_regexp news ^/news/([0-9]+)/([a-z\-_]+)/(.+)\.html?$ - rewrite @news /index.php?action=news&newsid={re.news.1}&newslang={re.news.2} - - # Sitemap pages - @sitemap path_regexp sitemap ^/sitemap/([^/]+)/([a-z\-_]+)\.html?$ - rewrite @sitemap /index.php?action=sitemap&letter={re.sitemap.1}&lang={re.sitemap.2} - - # Google sitemap - rewrite /sitemap.xml /sitemap.xml.php - rewrite /sitemap.gz /sitemap.xml.php?gz=1 - rewrite /sitemap.xml.gz /sitemap.xml.php?gz=1 - - # robots.txt - rewrite /robots.txt /robots.txt.php - - # llms.txt - rewrite /llms.txt /llms.txt.php - - # Tags pages with page count - @tags_page path_regexp tags_page ^/tags/([0-9]+)/([0-9]+)/(.+)\.html?$ - rewrite @tags_page /index.php?action=search&tagging_id={re.tags_page.1}&seite={re.tags_page.2} - - # Tags pages - @tags path_regexp tags ^/tags/([0-9]+)/([^/]+)\.html?$ - rewrite @tags /index.php?action=search&tagging_id={re.tags.1} - - # Authentication services - @webauthn path /services/webauthn* - rewrite @webauthn /services/webauthn/index.php - - # User pages - @user path_regexp user ^/user/(ucp|bookmarks|request-removal|logout|register)$ - rewrite @user /index.php?action={re.user.1} - - # Setup and update pages + # Setup pages @setup path /setup* rewrite @setup /setup/index.php + # Update page - route directly to index.php (handled by Symfony router) @update path /update* - rewrite @update /update/index.php + rewrite @update /index.php # Administration API - @admin_api path /admin/api* + @admin_api path /admin/api/* rewrite @admin_api /admin/api/index.php + # Redirect /admin to /admin/ + @admin_exact path_regexp ^/admin$ + redir @admin_exact /admin/ 301 + # Administration pages - @admin path /admin* - rewrite @admin /admin/index.php + handle_path /admin/* { + rewrite * /admin/index.php + } - # Private APIs - @api path_regexp api ^/api/(autocomplete|bookmark/delete|bookmark/create|user/data/update|user/password/update|user/request-removal|user/remove-twofactor|contact|voting|register|captcha|share|comment/create|faq/create|question/create|webauthn/prepare|webauthn/register|webauthn/prepare-login|webauthn/login)$ + # API routes (all API endpoints) + @api path /api/* rewrite @api /api/index.php - # Setup APIs - @api_setup path_regexp api_setup ^/api/setup/(check|backup|update-database)$ - rewrite @api_setup /api/index.php - - # REST API v3.0 and v3.1 - @api_v3 path_regexp api_v3 ^/api/v3\.[01]/(.*)$ - rewrite @api_v3 /api/index.php - file_server header { @@ -148,87 +79,43 @@ # TLS configuration with your certificate files tls /etc/ssl/cert.pem /etc/ssl/cert-key.pem + # PHP Handler for FrankenPHP php_server - # Gleiche Rewrite-Regeln wie für Port 80 + # Exclude assets from being handled by router @assets path /admin/assets* file_server @assets + # Error pages handle_errors 404 { - rewrite * /index.php?action=404 + rewrite * /404.html php_server } - rewrite /add-faq.html /index.php?action=add - rewrite /add-question.html /index.php?action=ask - rewrite /show-categories.html /index.php?action=show - rewrite /search.html /index.php?action=search - rewrite /open-questions.html /index.php?action=open-questions - rewrite /contact.html /index.php?action=contact - rewrite /glossary.html /index.php?action=glossary - rewrite /overview.html /index.php?action=overview - rewrite /login.html /index.php?action=login - rewrite /privacy.html /index.php?action=privacy - rewrite /login /index.php?action=login - rewrite /index.html /index.php - rewrite /bookmarks.html /index.php?action=bookmarks - - @solution_id path_regexp solution ^/solution_id_([0-9]+)\.html$ - rewrite @solution_id /index.php?solution_id={re.solution.1} - - @faq path_regexp faq ^/content/([0-9]+)/([0-9]+)/([a-z\-_]+)/(.+)\.html?$ - rewrite @faq /index.php?action=faq&cat={re.faq.1}&id={re.faq.2}&artlang={re.faq.3} - - @category_page path_regexp cat_page ^/category/([0-9]+)/([0-9]+)/(.+)\.html?$ - rewrite @category_page /index.php?action=show&cat={re.cat_page.1}&seite={re.cat_page.2} - - @category path_regexp cat ^/category/([0-9]+)/(.+)\.html?$ - rewrite @category /index.php?action=show&cat={re.cat.1} - - @news path_regexp news ^/news/([0-9]+)/([a-z\-_]+)/(.+)\.html?$ - rewrite @news /index.php?action=news&newsid={re.news.1}&newslang={re.news.2} - - @sitemap path_regexp sitemap ^/sitemap/([^/]+)/([a-z\-_]+)\.html?$ - rewrite @sitemap /index.php?action=sitemap&letter={re.sitemap.1}&lang={re.sitemap.2} - - rewrite /sitemap.xml /sitemap.xml.php - rewrite /sitemap.gz /sitemap.xml.php?gz=1 - rewrite /sitemap.xml.gz /sitemap.xml.php?gz=1 - rewrite /robots.txt /robots.txt.php - rewrite /llms.txt /llms.txt.php - - @tags_page path_regexp tags_page ^/tags/([0-9]+)/([0-9]+)/(.+)\.html?$ - rewrite @tags_page /index.php?action=search&tagging_id={re.tags_page.1}&seite={re.tags_page.2} - - @tags path_regexp tags ^/tags/([0-9]+)/([^/]+)\.html?$ - rewrite @tags /index.php?action=search&tagging_id={re.tags.1} - - @webauthn path /services/webauthn* - rewrite @webauthn /services/webauthn/index.php - - @user path_regexp user ^/user/(ucp|bookmarks|request-removal|logout|register)$ - rewrite @user /index.php?action={re.user.1} - + # Setup pages @setup path /setup* rewrite @setup /setup/index.php + # Update page - route directly to index.php (handled by Symfony router) @update path /update* - rewrite @update /update/index.php + rewrite @update /index.php - @admin_api path /admin/api* + # Administration API + @admin_api path /admin/api/* rewrite @admin_api /admin/api/index.php - @admin path /admin* - rewrite @admin /admin/index.php - - @api path_regexp api ^/api/(autocomplete|bookmark/delete|bookmark/create|user/data/update|user/password/update|user/request-removal|user/remove-twofactor|contact|voting|register|captcha|share|comment/create|faq/create|question/create|webauthn/prepare|webauthn/register|webauthn/prepare-login|webauthn/login)$ - rewrite @api /api/index.php + # Redirect /admin to /admin/ + @admin_exact path_regexp ^/admin$ + redir @admin_exact /admin/ 301 - @api_setup path_regexp api_setup ^/api/setup/(check|backup|update-database)$ - rewrite @api_setup /api/index.php + # Administration pages + handle_path /admin/* { + rewrite * /admin/index.php + } - @api_v3 path_regexp api_v3 ^/api/v3\.[01]/(.*)$ - rewrite @api_v3 /api/index.php + # API routes (all API endpoints) + @api path /api/* + rewrite @api /api/index.php file_server diff --git a/.docker/frankenphp/Dockerfile b/.docker/frankenphp/Dockerfile index 4600d1661c..7da9e91517 100644 --- a/.docker/frankenphp/Dockerfile +++ b/.docker/frankenphp/Dockerfile @@ -41,6 +41,10 @@ RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ RUN pecl install xdebug \ && docker-php-ext-enable xdebug +#=== Install redis PHP extension === +RUN pecl install redis \ + && docker-php-ext-enable redis + #=== Environment variables === ENV PMF_TIMEZONE="Europe/Berlin" \ PMF_ENABLE_UPLOADS=On \ diff --git a/.docker/nginx/default.conf b/.docker/nginx/default.conf index 8ed093078d..1991d8e1f2 100644 --- a/.docker/nginx/default.conf +++ b/.docker/nginx/default.conf @@ -24,6 +24,14 @@ server { gzip_buffers 16 8k; gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + # Service Worker - must be before other .js rules, no caching, proper headers + location ^~ /sw.js { + default_type application/javascript; + add_header Service-Worker-Allowed "/"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + } + location ~* \.(jpg|jpeg|gif|png|webp|svg|ico|mp4|webm|mpeg|ttf|otf|woff|woff2|css|js|pdf)$ { expires 1y; add_header Cache-Control "public"; @@ -59,15 +67,10 @@ server { add_header X-Frame-Options SAMEORIGIN; # Error pages - error_page 403 = @error403; error_page 404 = @error404; - location @error403 { - rewrite ^ /index.php?action=403 last; - } - location @error404 { - rewrite ^ /index.php?action=404 last; + rewrite ^ /404.html last; } location / { @@ -76,79 +79,26 @@ server { } location @rewriteapp { - # General pages - rewrite ^/add-faq\.html$ /index.php?action=add last; - rewrite ^/add-question\.html$ /index.php?action=ask last; - rewrite ^/show-categories\.html$ /index.php?action=show last; - rewrite ^/forgot-password/?$ /index.php?action=password last; - rewrite ^/(search|open-questions|help|contact|glossary|overview|login|privacy|index)\.html$ /index.php?action=$1 last; - rewrite ^/(login)$ /index.php?action=login last; - - # a solution id page - rewrite ^/solution_id_([0-9]+)\.html$ /index.php?solution_id=$1 last; - - # the bookmarks page - rewrite ^/bookmarks\.html$ /index.php?action=bookmarks last; - - # PMF faq record page - rewrite ^/content/([0-9]+)/([0-9]+)/([a-z\-_]+)/(.+)\.html$ /index.php?action=faq&cat=$1&id=$2&artlang=$3 last; - - # PMF category page with page count - rewrite ^/category/([0-9]+)/([0-9]+)/(.+)\.html$ /index.php?action=show&cat=$1&seite=$2 last; - - # PMF category page - rewrite ^/category/([0-9]+)/(.+)\.html$ /index.php?action=show&cat=$1 last; - - # PMF news page - rewrite ^/news/([0-9]+)/([a-z\-_]+)/(.+)\.html$ /index.php?action=news&newsid=$1&newslang=$2 last; - - # PMF sitemap - rewrite ^/sitemap/([^\/]+)/([a-z\-_]+)\.htm(l?)$ /index.php?action=sitemap&letter=$1&lang=$2 last; - - # PMF Google sitemap - rewrite ^/sitemap\.xml$ /sitemap.xml.php last; - rewrite ^/sitemap\.gz$ /sitemap.xml.php?gz=1 last; - rewrite ^/sitemap\.xml\.gz$ /sitemap.xml.php?gz=1 last; - - # robots.txt - rewrite ^/robots\.txt$ /robots.txt.php last; - - # llms.txt - rewrite ^/llms\.txt$ /llms.txt.php last; - - # PMF tags page with page count - rewrite ^/tags/([0-9]+)/([0-9]+)/(.+)\.htm(l?)$ /index.php?action=search&tagging_id=$1&seite=$2 last; - - # PMF tags page - rewrite ^/tags/([0-9]+)/([^\/]+)\.htm(l?)$ /index.php?action=search&tagging_id=$1 last; - - # Authentication services - rewrite ^/services/webauthn/(.*)$ /services/webauthn/index.php last; - - # User pages - rewrite ^/user/(ucp|bookmarks|request-removal|logout|register)$ /index.php?action=$1 last; # Administration API - rewrite ^/admin/api/(.*)$ /admin/api/index.php last; + rewrite ^/admin/api/ /admin/api/index.php last; - # Administration pages - rewrite ^/admin/(.*)$ /admin/index.php last; + # Administration pages (redirect /admin to /admin/) + rewrite ^/admin$ /admin/ permanent; + rewrite ^/admin/ /admin/index.php last; - # Private APIs - rewrite ^/api/(.*)$ /api/index.php last; + # API routes (all API endpoints) + rewrite ^/api/ /api/index.php last; - # REST API v3.0 and v3.1 - rewrite ^api/v3\.[01]/(.*)$ /api/index.php last; - - # Setup APIs - rewrite ^/api/setup/(check|backup|update-database)$ /api/index.php last; + # Setup pages + rewrite ^/setup/ /setup/index.php last; - # Setup and update pages - rewrite ^/setup/?$ /setup/index.php last; - rewrite ^/update/?$ /update/index.php last; + # Update page - route directly to index.php (handled by Symfony router) + rewrite ^/update$ /index.php last; + rewrite ^/update/ /index.php last; - # Fallback: return 404 if no rewrite rule matched - return 404; + # Front controller: route all other requests to index.php (Symfony Router) + rewrite ^ /index.php last; } location /admin/assets { @@ -208,6 +158,14 @@ server { gzip_buffers 16 8k; gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + # Service Worker - must be before other .js rules, no caching, proper headers + location ^~ /sw.js { + default_type application/javascript; + add_header Service-Worker-Allowed "/"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + } + location ~* \.(jpg|jpeg|gif|png|webp|svg|ico|mp4|webm|mpeg|ttf|otf|woff|woff2|css|js|pdf)$ { expires 1y; add_header Cache-Control "public"; @@ -243,15 +201,10 @@ server { add_header X-Frame-Options SAMEORIGIN; # Error pages - error_page 403 = @error403; error_page 404 = @error404; - location @error403 { - rewrite ^ /index.php?action=403 last; - } - location @error404 { - rewrite ^ /index.php?action=404 last; + rewrite ^ /404.html last; } location / { @@ -260,76 +213,26 @@ server { } location @rewriteapp { - # General pages - rewrite ^/add-faq\.html$ /index.php?action=add last; - rewrite ^/add-question\.html$ /index.php?action=ask last; - rewrite ^/show-categories\.html$ /index.php?action=show last; - rewrite ^/forgot-password/?$ /index.php?action=password last; - rewrite ^/(search|open-questions|help|contact|glossary|overview|login|privacy|index)\.html$ /index.php?action=$1 last; - rewrite ^/(login)$ /index.php?action=login last; - - # a solution id page - rewrite ^/solution_id_([0-9]+)\.html$ /index.php?solution_id=$1 last; - - # the bookmarks page - rewrite ^/bookmarks\.html$ /index.php?action=bookmarks last; - - # PMF faq record page - rewrite ^/content/([0-9]+)/([0-9]+)/([a-z\-_]+)/(.+)\.html$ /index.php?action=faq&cat=$1&id=$2&artlang=$3 last; - - # PMF category page with page count - rewrite ^/category/([0-9]+)/([0-9]+)/(.+)\.html$ /index.php?action=show&cat=$1&seite=$2 last; - - # PMF category page - rewrite ^/category/([0-9]+)/(.+)\.html$ /index.php?action=show&cat=$1 last; - - # PMF news page - rewrite ^/news/([0-9]+)/([a-z\-_]+)/(.+)\.html$ /index.php?action=news&newsid=$1&newslang=$2 last; - - # PMF sitemap - rewrite ^/sitemap/([^\/]+)/([a-z\-_]+)\.htm(l?)$ /index.php?action=sitemap&letter=$1&lang=$2 last; - - # PMF Google sitemap - rewrite ^/sitemap\.xml$ /sitemap.xml.php last; - rewrite ^/sitemap\.gz$ /sitemap.xml.php?gz=1 last; - rewrite ^/sitemap\.xml\.gz$ /sitemap.xml.php?gz=1 last; - - # robots.txt - rewrite ^/robots\.txt$ /robots.txt.php last; - - # llms.txt - rewrite ^/llms\.txt$ /llms.txt.php last; - - # PMF tags page with page count - rewrite ^/tags/([0-9]+)/([0-9]+)/(.+)\.htm(l?)$ /index.php?action=search&tagging_id=$1&seite=$2 last; - - # PMF tags page - rewrite ^/tags/([0-9]+)/([^\/]+)\.htm(l?)$ /index.php?action=search&tagging_id=$1 last; - - # Authentication services - rewrite ^/services/webauthn(.*)$ /services/webauthn/index.php last; - - # User pages - rewrite ^/user/(ucp|bookmarks|request-removal|logout|register)$ /index.php?action=$1 last; # Administration API - rewrite ^/admin/api/(.*)$ /admin/api/index.php last; + rewrite ^/admin/api/ /admin/api/index.php last; - # Administration pages - rewrite ^/admin/(.*)$ /admin/index.php last; + # Administration pages (redirect /admin to /admin/) + rewrite ^/admin$ /admin/ permanent; + rewrite ^/admin/ /admin/index.php last; - # REST API v3.0 and v3.1 - rewrite ^api/v3\.[01]/(.*)$ /api/index.php last; + # API routes (all API endpoints) + rewrite ^/api/ /api/index.php last; - # Private APIs - rewrite ^/api/(.*)$ /api/index.php last; - - # Setup and update pages + # Setup pages rewrite ^/setup/ /setup/index.php last; - rewrite ^/update/ /update/index.php last; - # Fallback: return 404 if no rewrite rule matched - return 404; + # Update page - route directly to index.php (handled by Symfony router) + rewrite ^/update$ /index.php last; + rewrite ^/update/ /index.php last; + + # Front controller: route all other requests to index.php (Symfony Router) + rewrite ^ /index.php last; } location /admin/assets { diff --git a/.docker/php-fpm/Dockerfile b/.docker/php-fpm/Dockerfile index 6eed33c2f4..1a64879962 100644 --- a/.docker/php-fpm/Dockerfile +++ b/.docker/php-fpm/Dockerfile @@ -1,12 +1,12 @@ # -# This image uses a php:8.4-fpm base image and does not have any phpMyFAQ code with it. +# This image uses a php:8.5-fpm base image and does not have any phpMyFAQ code with it. # It's for development only, it's meant to be run with docker-compose # ##################################### #=== Unique stage without payload === ##################################### -FROM php:8.4-fpm +FROM php:8.5-fpm #=== Install gd PHP dependencies === RUN set -x \ @@ -61,6 +61,10 @@ RUN set -ex \ RUN pecl install xdebug-3.5.0 \ && docker-php-ext-enable xdebug +#=== Install redis PHP extension === +RUN pecl install redis \ + && docker-php-ext-enable redis + #=== php default === ENV PMF_TIMEZONE="Europe/Berlin" \ PMF_ENABLE_UPLOADS=On \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc112cdb22..55787b245c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: operating-system: ['ubuntu-latest'] - php-versions: ['8.3', '8.4', '8.5', '8.6'] + php-versions: ['8.4', '8.5', '8.6'] name: phpMyFAQ ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} steps: diff --git a/.github/workflows/dev-nightly.yml b/.github/workflows/dev-nightly.yml index 23cb55fd77..8794849a5c 100644 --- a/.github/workflows/dev-nightly.yml +++ b/.github/workflows/dev-nightly.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: operating-system: ['ubuntu-latest'] - php-versions: ['8.3'] + php-versions: ['8.4'] name: phpMyFAQ Nightly Dev Build steps: diff --git a/.gitignore b/.gitignore index 4859afac5d..b3e25322b7 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ phpmyfaq/assets/svg/*.svg # Twig caches phpmyfaq/admin/assets/cache/ +# Route caches +phpmyfaq/cache/ + # Node.js modules for development node_modules/* .pnpm-store diff --git a/CHANGELOG.md b/CHANGELOG.md index 52682cd375..474d5ae1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,45 @@ -# phpMyFAQ 4.1 +# phpMyFAQ 4.2 -**Codename "Porus"** +**Codename "Palaimon"** ## CHANGELOG This is a log of major user-visible changes in each phpMyFAQ release. + +### phpMyFAQ v4.2.0-alpha - unreleased + +- changed PHP requirement to PHP 8.4 or later (Thorsten) +- added Symfony Router for frontend (Thorsten) +- added API for glossary definitions (Thorsten) +- added admin log CSV export feature (Thorsten) +- added pagination, sorting, and filtering for APIs (Thorsten) +- added support for custom pages with WYSIWYG editor, SEO features, multi-language support, and search integration (Thorsten) +- added a translation adapter system with support for Google Cloud Translation, DeepL, Azure Translator, Amazon Translate, and LibreTranslate (Thorsten) +- added a simple chat for users (Thorsten) +- added push notifications via Web Push API (Thorsten) +- added support for Flesch readability tests (Thorsten) +- added storage abstraction layer with support for local filesystem, and Amazon S3 (Thorsten) +- added support for SendGrid, AWS SES, and Mailgun (Thorsten) +- added theme manager with support for multiple themes and theme switching (Thorsten) +- added experimental support for API key authentication via OAuth2 (Thorsten) +- added experimental per-tenant quota enforcement, and API request rate limits (Thorsten) +- improved audit and activity log with comprehensive security event tracking (Thorsten) +- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten) +- improved support for PDO (Thorsten) +- improved sticky FAQs administration (Thorsten) +- improved update process (Thorsten) +- improved and hardened multi tenancy support (Thorsten) +- improved and redesigned searchable admin configuration frontend (Thorsten) +- migrated codebase using PHP 8.4 language features (Thorsten) +- migrated routes using PHP 8+ #[Route] attributes (Thorsten) + ### phpMyFAQ v4.1.0-RC.5 - 2026-02-13 - changed PHP requirement to PHP 8.3 or later (Thorsten) - added configuration to edit robots.txt (Thorsten) - added configuration to edit llms.txt (Thorsten) -- added Symfony Routing for administration backend (Thorsten) +- added Symfony Router for administration backend (Thorsten) - added code snippets plugin with syntax highlighting in WYSIWYG editor (Thorsten) - added an administration view for orphaned FAQs (Thorsten) - added plugin administration backend (Thorsten) @@ -627,7 +655,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - removed bundled SyntaxHighlighter (Thorsten) - dropped support for ext/mysql (Thorsten) - dropped support for SQLite2 (Thorsten) -- dropped support for Zeus Webserver, IIS 6 and lighttpd (Thorsten) +- dropped support for Zeus Webserver, IIS 6, and lighttpd (Thorsten) - fixed a lot of minor bugs (Thorsten) ### phpMyFAQ v2.8.29 - 2016-05-31 @@ -853,7 +881,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - improved CSS development with LESS (Thorsten) - improved minified CSS output (Thorsten) - simplified the link verification (Thorsten) -- dropped support for IBM DB2, Interbase/Firebird and Sybase (Thorsten) +- dropped support for IBM DB2, Interbase/Firebird, and Sybase (Thorsten) - dropped support for PHP register_globals and magic_quotes_gpc (Thorsten) - dropped support for Google Translate API v1 (Thorsten) - removed Delicious support (Thorsten) @@ -1893,7 +1921,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - phpMyFAQ is now Open Source software - template system for free layouts -- fully compatible with PHP 4.1, PHP 4.2 and PHP 4.3 (register_globals = off) +- fully compatible with PHP 4.1, PHP 4.2, and PHP 4.3 (register_globals = off) - all color and font definitions with CSS - better SQL queries - better category navigation diff --git a/CLAUDE.md b/CLAUDE.md index a4ab35294c..53d4129527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,12 @@ It is built using HTML5, CSS, TypeScript, and PHP and supports various databases ## Tech stack, libraries, and frameworks - HTML 5, SCSS, TypeScript, Bootstrap, and Bootstrap Icons for the frontend. TypeScript in strict mode. -- PHP 8.3 and later with Symfony components for the backend. +- PHP 8.4 and later with Symfony components for the backend. - MySQL, PostgreSQL, SQLite3, and MS SQL for data storage. This option is configurable. - Elasticsearch and OpenSearch for search functionality. This option is configurable. - Apache, Nginx, and IIS as supported web servers. This option is configurable. - It uses PNPM as the package manager for JavaScript/TypeScript dependencies. -- It used Composer as the package manager for PHP dependencies. +- It uses Composer as the package manager for PHP dependencies. - Twig as the templating engine. - PHPUnit v12 for PHP-based unit testing, vitest for TypeScript-based unit testing. - Docker for containerization. @@ -54,6 +54,7 @@ It is built using HTML5, CSS, TypeScript, and PHP and supports various databases - TypeScript code in watch mode: pnpm test:watch - TypeScript linting: pnpm lint - TypeScript code formatting: pnpm lint:fix +- TypeScript errors have to be fixed before committing code. ## Building @@ -64,12 +65,114 @@ It is built using HTML5, CSS, TypeScript, and PHP and supports various databases ## Coding Standards - Use PER Coding Style 3.0 for PHP code. -- Use TypeScript coding standards for TypeScript code. +- Use TypeScript coding standards for TypeScript code in strict mode. - Use HTML5 and CSS3 standards for frontend code. - Use semicolons at the end of each statement. - Use single quotes for strings. - Use arrow functions for callbacks. +## Routing System + +The application uses Symfony Router with PHP 8+ Route attributes for modern, controller-based routing. + +### Architecture + +1. **Entry Points**: + - `phpmyfaq/index.php`: Frontend entry point + - `phpmyfaq/admin/index.php`: Admin panel entry point + - `phpmyfaq/api/index.php`: API entry point +2. **AttributeRouteLoader**: Automatically discovers routes from controller #[Route] attributes +3. **RouteCollectionBuilder**: Builds route collections for different contexts (public, admin, api, admin-api) +4. **RouteCacheManager**: Caches compiled routes for production performance +5. **Controllers**: Modern Controller classes extending AbstractController +6. **services.php**: Dependency injection configuration for services and classes + +### Adding New Routes + +All routes are defined using PHP 8+ #[Route] attributes directly on controller methods. No separate route definition files are needed. + +To add a new route: + +1. Create a Controller in the appropriate directory: + - Frontend routes: `phpmyfaq/src/phpMyFAQ/Controller/Frontend/` + - Admin routes: `phpmyfaq/src/phpMyFAQ/Controller/Administration/` + - API routes: `phpmyfaq/src/phpMyFAQ/Controller/Api/` + - Admin API routes: `phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/` +2. Add the #[Route] attribute to your controller method +3. The Controller should extend `AbstractController` (or `AbstractAdministrationApiController` for admin API) + +Example: + +```php +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class MyController extends AbstractController +{ + #[Route(path: '/my-page.html', name: 'public.my-page', methods: ['GET'])] + public function index(Request $request): Response + { + return $this->render('template.twig', ['data' => 'value']); + } +} +``` + +### Route Naming Conventions + +- **Frontend routes**: `public.{resource}.{action}` (e.g., `public.faq.show`, `public.user.register`) +- **Admin routes**: `admin.{resource}.{action}` (e.g., `admin.faq.edit`, `admin.category.add`) +- **API routes**: `api.{resource}.{action}` (e.g., `api.search`, `api.faqs.list`) +- **Admin API routes**: `admin.api.{resource}.{action}` (e.g., `admin.api.faq.create`) + +### Route Parameters + +Use curly braces `{param}` for route parameters: + +```php +#[Route(path: '/faq/{categoryId}/{faqId}', name: 'public.faq.show', methods: ['GET'])] +public function show(Request $request, int $categoryId, int $faqId): Response +{ + // Parameters are automatically extracted from the URL + $categoryId = $request->attributes->get('categoryId'); + $faqId = $request->attributes->get('faqId'); + // ... +} +``` + +### Route Caching + +Route caching improves performance by caching the compiled route collection, eliminating the need to scan controllers and use reflection on every request. + +**Configuration via Environment Variables:** + +Create a `.env` file in `phpmyfaq/` directory (copy from `.env.example`): + +```env +# Enable route caching in production for ~98% performance improvement +ROUTING_CACHE_ENABLED=true + +# Cache directory is automatically set to {PMF_ROOT_DIR}/cache/routes +# Only override if you need a custom location (must be an absolute path) +# ROUTING_CACHE_DIR=/custom/path/to/cache +``` + +**Behavior:** +- **Production**: Routes are cached to PHP files, loaded instantly on subsequent requests +- **Development/Debug Mode**: Cache is automatically disabled (DEBUG=true) for immediate route changes +- **Performance**: ~98% faster route loading (21ms → 0.45ms for 39 routes) + +**Cache Management:** + +The cache is automatically cleared when: +- Debug mode is enabled +- The environment variable `ROUTING_CACHE_ENABLED` is set to `false` + +To manually clear the route cache, delete the cache directory: +```bash +rm -rf phpmyfaq/cache/routes +``` + ## UI guidelines - Application should have a modern and clean design. diff --git a/Dockerfile b/Dockerfile index 7d6d424657..b42fb19af6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # # Stage 1: Composer dependencies with PHP extensions -FROM php:8.5.0RC5-cli AS composer +FROM php:8.5.2-cli AS composer RUN apt-get update && apt-get install -y \ git unzip libpng-dev libjpeg-dev libfreetype6-dev libzip-dev libicu-dev libpq-dev libldap2-dev libbz2-dev libsodium-dev \ @@ -22,7 +22,7 @@ COPY composer.json composer.lock ./ RUN composer install --no-interaction --prefer-dist --optimize-autoloader --verbose # Stage 2: PHP runtime with same extensions -FROM php:8.5.0RC5-cli +FROM php:8.5.2-cli # Install necessary system dependencies again (clean stage) RUN apt-get update && apt-get install -y \ diff --git a/README.md b/README.md index 20f3fffdb1..1167c4b67c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# phpMyFAQ 4.1-dev +# phpMyFAQ 4.2-dev ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/thorsten/phpMyFAQ) ![GitHub](https://img.shields.io/github/license/thorsten/phpMyFAQ) @@ -7,28 +7,42 @@ ## What is phpMyFAQ? -phpMyFAQ is a multilingual, completely database-driven FAQ system. -It supports various databases to store all data; PHP 8.3+ is needed to access this data. -phpMyFAQ also offers a multi-language Content Management System with a WYSIWYG editor and a media manager, real time -search support with Elasticsearch and OpenSearch, flexible multi-user support with user and group based permissions on -categories and records, a wiki-like revision feature, a news system, user-tracking, 40+ supported languages, enhanced -automatic content negotiation, HTML5/CSS3 based responsive templates, PDF-support, a backup and restore system, a -dynamic sitemap, related FAQs, tagging, enhanced SEO features, built-in spam protection systems, Microsoft Entra ID, -Microsoft Active Directory and OpenLDAP support, a experimental MCP server, and an easy-to-use installation. -It's possible to update your phpMyFAQ installation via the web interface or on the command line. +phpMyFAQ is a comprehensive, multilingual FAQ system that is entirely database-driven. +It is compatible with a variety of databases for data storage and requires PHP 8.4+ for data access. +The system features a multi-language Content Management System equipped with a WYSIWYG editor and an Image Manager. +It also provides real-time search capabilities with Elasticsearch or OpenSearch. + +phpMyFAQ supports flexible multi-user functionality, offering user and group-based permissions on categories and FAQs. +It includes a wiki-like revision feature, a news system, and configurable user-tracking. +Administrators can monitor user activities through detailed log files. +Additionally, phpMyFAQ supports adding its own custom pages to the FAQ system. +With support for over 40 languages, it also boasts enhanced automatic content negotiation and HTML5- / CSS3-based +responsive templates. +These Twig-based templates allow for the inclusion of your own text and HTML snippets. There's also a built-in plugin +system for further customization. + +Additional features include PDF support, a backup system, a dynamic sitemap, related FAQs, tagging, a plugin system, +and built-in spam protection systems. +phpMyFAQ also supports two-factor authentication (2FA) for enhanced security. +A REST API is available for integration with other systems. +It also supports OpenLDAP, Microsoft Active Directory, Microsoft Entra ID, and an MCP Server for AI agents. +The system is easy to install, thanks to its user-friendly installation script. + +phpMyFAQ is versatile +and can be run on almost any web hosting provider or deployed in the cloud using a Docker container. ## Requirements -phpMyFAQ is only supported on PHP 8.3 and up, you need a database as well. Supported databases are MySQL, MariaDB, -Percona Server, PostgreSQL, Microsoft SQL Server and SQLite3. If you want to use Elasticsearch or Opensearch as the main -search engine, you need Elasticsearch v6+ or OpenSearch v1+. Check our detailed requirements on +phpMyFAQ is only supported on PHP 8.4+, you need a database as well. Supported databases are MySQL, MariaDB, +Percona Server, PostgreSQL, Microsoft SQL Server, and SQLite3. If you want to use Elasticsearch or Opensearch as the +main search engine, you need Elasticsearch v6+ or OpenSearch v1+. Check our detailed requirements on [phpmyfaq.de](https://www.phpmyfaq.de/requirements) for more information. ## Installation ### phpMyFAQ installation package for end-users -The best way to install phpMyFAQ is to download it on [phpmyfaq.de](https://www.phpmyfaq.de/download), unzip the package +The best way to install phpMyFAQ is to download it on [phpmyfaq.de](https://www.phpmyfaq.de/download), unzip the package, and open http://www.example.org/phpmyfaq/setup/ in your preferred browser. ### phpMyFAQ installation with Docker @@ -61,17 +75,18 @@ _Running using named volumes:_ - **sqlserver**: image with Microsoft SQL Server for Linux - **elasticsearch**: Open Source Software image (it means it does not have XPack installed) - **opensearch**: OpenSearch image (it means it does not have XPack installed) +- **redis**: image with a Redis database -_Running apache web server with PHP 8.4 support:_ +_Running apache web server with PHP 8.5 support:_ - **apache**: mounts the `phpmyfaq` folder in place of `/var/www/html`. -_Running nginx web server with PHP 8.4 support:_ +_Running nginx web server with PHP 8.5 support:_ - **nginx**: mounts the `phpmyfaq` folder in place of `/var/www/html`. -- **php-fpm**: PHP-FPM image with PHP 8.4 support +- **php-fpm**: PHP-FPM image with PHP 8.5 support -_Running FrankenPHP web server with PHP 8.4 support:_ +_Running FrankenPHP web server with PHP 8.5 support:_ - **frankenphp**: mounts the `phpmyfaq` folder in place of `/var/www/html`. @@ -165,7 +180,7 @@ And constructed with the following guidelines: - Breaking backward compatibility bumps the major (and resets the minor and patch) - New additions without breaking backward compatibility bump the minor (and reset the patch) -- Bug fixes and misc changes bumps the patch +- Bug fixes and misc changes bump the patch For more information on SemVer, please visit http://semver.org/. diff --git a/bin/scheduler.php b/bin/scheduler.php new file mode 100755 index 0000000000..12bd3b2a59 --- /dev/null +++ b/bin/scheduler.php @@ -0,0 +1,43 @@ +#!/usr/bin/env php + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-13 + */ + +declare(strict_types=1); + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; + +require __DIR__ . '/../phpmyfaq/src/Bootstrap.php'; +require __DIR__ . '/../phpmyfaq/src/autoload.php'; + +$command = $argv[1] ?? ''; +if ($command !== 'run') { + fwrite(STDERR, "Usage: php bin/scheduler.php run\n"); + exit(1); +} + +$container = new ContainerBuilder(); +$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); +$loader->load('../phpmyfaq/src/services.php'); +$container->compile(); + +$scheduler = $container->get('phpmyfaq.scheduler.task-scheduler'); +$results = $scheduler->run(); + +echo "Task scheduler finished.\n"; +echo json_encode($results, JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/bin/worker.php b/bin/worker.php new file mode 100644 index 0000000000..465e2260df --- /dev/null +++ b/bin/worker.php @@ -0,0 +1,39 @@ +#!/usr/bin/env php + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-11 + */ + +declare(strict_types=1); + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; + +require __DIR__ . '/../phpmyfaq/src/Bootstrap.php'; +require __DIR__ . '/../phpmyfaq/src/autoload.php'; + +$maxJobs = isset($argv[1]) ? max(0, (int) $argv[1]) : 0; + +$container = new ContainerBuilder(); +$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); +$loader->load('../phpmyfaq/src/services.php'); +$container->compile(); + +$worker = $container->get('phpmyfaq.queue.worker'); +$processed = $worker->run($maxJobs); + +echo sprintf("Processed %d job(s).\n", $processed); + diff --git a/composer.json b/composer.json index 5dda714bfa..05113399e3 100644 --- a/composer.json +++ b/composer.json @@ -15,40 +15,45 @@ } ], "require": { - "php": ">=8.3.0", + "php": ">=8.4.0", "ext-curl": "*", "ext-fileinfo": "*", "ext-filter": "*", "ext-gd": "*", "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", "ext-sodium": "*", "ext-xml": "*", "ext-xmlwriter": "*", "ext-zip": "*", "2tvenom/cborencode": "^1.0", + "aws/aws-sdk-php": "^3.0", "elasticsearch/elasticsearch": "8.*", "endroid/qr-code": "^6.0.2", "guzzlehttp/guzzle": "^7.5", "league/commonmark": "^2.4", + "league/oauth2-server": "^9.2", + "minishlink/web-push": "^10.0", "monolog/monolog": "^3.3", "myclabs/deep-copy": "~1.0", "opensearch-project/opensearch-php": "^2.4", "phpseclib/phpseclib": "~3.0", "robthree/twofactorauth": "^3.0.0", - "symfony/config": "^7.3", - "symfony/console": "^7.3", - "symfony/dependency-injection": "^7.3", - "symfony/dotenv": "^7.3", - "symfony/event-dispatcher": "^7.3", - "symfony/error-handler": "^7.3", - "symfony/html-sanitizer": "^7.3", - "symfony/http-client": "^7.3", - "symfony/http-foundation": "^7.3", - "symfony/http-kernel": "^7.3", - "symfony/mailer": "^7.3", + "symfony/config": "^8.0", + "symfony/console": "^8.0", + "symfony/dependency-injection": "^8.0", + "symfony/dotenv": "^8.0", + "symfony/error-handler": "^8.0", + "symfony/event-dispatcher": "^8.0", + "symfony/html-sanitizer": "^8.0", + "symfony/http-client": "^8.0", + "symfony/http-foundation": "^8.0", + "symfony/http-kernel": "^8.0", + "symfony/mailer": "^8.0", "symfony/mcp-sdk": "dev-main", - "symfony/routing": "^7.3", - "symfony/uid": "^7.3", + "symfony/routing": "^8.0", + "symfony/uid": "^8.0", "tecnickcom/tcpdf": "~6.0", "tivie/htaccess-parser": "0.4.0", "twig/intl-extra": "^3.10", @@ -58,15 +63,17 @@ "carthage-software/mago": "^1.0.1", "doctrine/instantiator": "2.*", "mikey179/vfsstream": "^1.6", - "phpdocumentor/reflection-docblock": "5.*", + "phpdocumentor/reflection-docblock": "6.*", "phpunit/phpunit": "^12.3", "rector/rector": "^2", - "symfony/yaml": "7.*", - "zircote/swagger-php": "^5.0" + "symfony/yaml": "8.*", + "zircote/swagger-php": "^6.0" }, "suggest": { + "ext-bcmath": "*", + "ext-frankenphp": "*", + "ext-gmp": "*", "ext-ldap": "*", - "ext-openssl": "*", "ext-pdo": "*", "ext-pgsql": "*", "ext-sqlite3": "*", @@ -74,7 +81,7 @@ }, "config": { "platform": { - "php": "8.3.0" + "php": "8.4.0" }, "process-timeout": 0, "secure-http": true, diff --git a/composer.lock b/composer.lock index c9f37671a9..d805881531 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d8a38630fff9570827d499e65c780b9d", + "content-hash": "79ff3c92e8f8cb849e567308e01471bf", "packages": [ { "name": "2tvenom/cborencode", @@ -51,6 +51,157 @@ }, "time": "2020-10-27T07:22:41+00:00" }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.369.33", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "27a14b3822c253cb98465c2e43f4e68b153a63f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/27a14b3822c253cb98465c2e43f4e68b153a63f4", + "reference": "27a14b3822c253cb98465c2e43f4e68b153a63f4", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^9.6", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.33" + }, + "time": "2026-02-12T19:07:01+00:00" + }, { "name": "bacon/bacon-qr-code", "version": "v3.0.3", @@ -106,6 +257,66 @@ }, "time": "2025-11-19T17:15:36+00:00" }, + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -156,6 +367,73 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -492,26 +770,26 @@ }, { "name": "endroid/qr-code", - "version": "6.0.9", + "version": "6.1.3", "source": { "type": "git", "url": "https://github.com/endroid/qr-code.git", - "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a" + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/endroid/qr-code/zipball/21e888e8597440b2205e2e5c484b6c8e556bcd1a", - "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", - "php": "^8.2" + "php": "^8.4" }, "require-dev": { "endroid/quality": "dev-main", "ext-gd": "*", - "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "khanamiryan/qrcode-detector-decoder": "^2.0.3", "setasign/fpdf": "^1.8.2" }, "suggest": { @@ -552,7 +830,7 @@ ], "support": { "issues": "https://github.com/endroid/qr-code/issues", - "source": "https://github.com/endroid/qr-code/tree/6.0.9" + "source": "https://github.com/endroid/qr-code/tree/6.1.3" }, "funding": [ { @@ -560,7 +838,7 @@ "type": "github" } ], - "time": "2025-07-13T19:59:45+00:00" + "time": "2026-02-05T07:01:58+00:00" }, { "name": "ezimuel/guzzlestreams", @@ -998,6 +1276,143 @@ ], "time": "2025-08-23T21:21:41+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.5.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/a3139d9e97d47826f27e6a17bb63f13621f86058", + "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058", + "shasum": "" + }, + "require": { + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.31", + "lcobucci/coding-standard": "^11.2.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^2.0.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0.0", + "phpstan/phpstan-strict-rules": "^2.0.0", + "phpunit/phpunit": "^12.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.5.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-27T09:03:17+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.8.0", @@ -1187,6 +1602,161 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, + { + "name": "league/oauth2-server", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d8e2f39f645a82b207bbac441694d6e6079357cb", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/clock": "^2.3 || ^3.0", + "lcobucci/jwt": "^5.0", + "league/event": "^3.0", + "league/uri": "^7.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.12|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1.4|^2.0", + "phpstan/phpstan-phpunit": "^1.3.15|^2.0", + "phpstan/phpstan-strict-rules": "^1.5.2|^2.0", + "phpunit/phpunit": "^10.5|^11.5|^12.0", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.14.1", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2025-11-25T22:51:15+00:00" + }, { "name": "league/uri", "version": "7.8.0", @@ -1370,35 +1940,44 @@ "time": "2026-01-15T06:54:53+00:00" }, { - "name": "masterminds/html5", - "version": "2.10.0", + "name": "minishlink/web-push", + "version": "v10.0.1", "source": { "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "08463189d3501cbd78a8625c87ab6680a7397aad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/08463189d3501cbd78a8625c87ab6680a7397aad", + "reference": "08463189d3501cbd78a8625c87ab6680a7397aad", "shasum": "" }, "require": { - "ext-dom": "*", - "php": ">=5.3.0" + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.2", + "spomky-labs/base64url": "^2.0.4", + "web-token/jwt-library": "^3.4.9|^4.0.6" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "friendsofphp/php-cs-fixer": "^v3.91.3", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." }, + "type": "library", "autoload": { "psr-4": { - "Masterminds\\": "src" + "Minishlink\\WebPush\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1407,34 +1986,25 @@ ], "authors": [ { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" } ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" + "Push API", + "WebPush", + "notifications", + "push", + "web" ], "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2025-12-15T10:04:28+00:00" }, { "name": "monolog/monolog", @@ -1516,28 +2086,94 @@ "homepage": "https://seld.be" } ], - "description": "Sends your logs to files, sockets, inboxes, databases and various web services", - "homepage": "https://github.com/Seldaek/monolog", + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", "keywords": [ - "log", - "logging", - "psr-3" + "json", + "jsonpath" ], "support": { - "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", - "type": "tidelift" - } - ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2024-09-04T18:46:31+00:00" }, { "name": "myclabs/deep-copy", @@ -2387,6 +3023,54 @@ ], "time": "2026-01-27T09:17:28+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" + }, { "name": "psr/container", "version": "2.0.2", @@ -2650,6 +3334,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -2898,36 +3695,209 @@ ], "time": "2026-01-05T13:17:41+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-20T12:57:40+00:00" + }, { "name": "symfony/config", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "4275b53b8ab0cf37f48bf273dc2285c8178efdfb" + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/4275b53b8ab0cf37f48bf273dc2285c8178efdfb", - "reference": "4275b53b8ab0cf37f48bf273dc2285c8178efdfb", + "url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.1|^8.0", - "symfony/polyfill-ctype": "~1.8" + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -2955,7 +3925,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.4" + "source": "https://github.com/symfony/config/tree/v8.0.4" }, "funding": [ { @@ -2975,51 +3945,43 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-01-13T13:06:50+00:00" }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "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" + "symfony/string": "^7.4|^8.0" }, "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" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3053,7 +4015,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v8.0.4" }, "funding": [ { @@ -3073,43 +4035,40 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-01-13T13:06:50+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.5", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "76a02cddca45a5254479ad68f9fa274ead0a7ef2" + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/76a02cddca45a5254479ad68f9fa274ead0a7ef2", - "reference": "76a02cddca45a5254479ad68f9fa274ead0a7ef2", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^3.6", - "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "conflict": { - "ext-psr": "<1.1|>=2", - "symfony/config": "<6.4", - "symfony/finder": "<6.4", - "symfony/yaml": "<6.4" + "ext-psr": "<1.1|>=2" }, "provide": { "psr/container-implementation": "1.1|2.0", "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3137,7 +4096,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.5" + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.5" }, "funding": [ { @@ -3157,7 +4116,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-01-27T16:18:07+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3228,28 +4187,24 @@ }, { "name": "symfony/dotenv", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "1658a4d34df028f3d93bcdd8e81f04423925a364" + "reference": "460b4067a85288c59a59ce8c1bfb3942e71fd85c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/1658a4d34df028f3d93bcdd8e81f04423925a364", - "reference": "1658a4d34df028f3d93bcdd8e81f04423925a364", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/460b4067a85288c59a59ce8c1bfb3942e71fd85c", + "reference": "460b4067a85288c59a59ce8c1bfb3942e71fd85c", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "conflict": { - "symfony/console": "<6.4", - "symfony/process": "<6.4" + "php": ">=8.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3282,7 +4237,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.0" + "source": "https://github.com/symfony/dotenv/tree/v8.0.0" }, "funding": [ { @@ -3302,37 +4257,36 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2025-11-16T10:17:21+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -3364,7 +4318,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v8.0.4" }, "funding": [ { @@ -3384,28 +4338,28 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-01-23T11:07:10+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -3414,14 +4368,14 @@ }, "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/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3449,7 +4403,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" }, "funding": [ { @@ -3469,7 +4423,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-01-05T11:45:55+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -3549,25 +4503,25 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3595,7 +4549,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" }, "funding": [ { @@ -3615,28 +4569,26 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "5b0bbcc3600030b535dd0b17a0e8c56243f96d7f" + "reference": "b091fe14296544172b1ec810cbd0af42e8d8ce89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/5b0bbcc3600030b535dd0b17a0e8c56243f96d7f", - "reference": "5b0bbcc3600030b535dd0b17a0e8c56243f96d7f", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/b091fe14296544172b1ec810cbd0af42e8d8ce89", + "reference": "b091fe14296544172b1ec810cbd0af42e8d8ce89", "shasum": "" }, "require": { "ext-dom": "*", "league/uri": "^6.5|^7.0", - "masterminds/html5": "^2.7.2", - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -3669,7 +4621,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.0" + "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.0" }, "funding": [ { @@ -3689,35 +4641,31 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:39:42+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.5", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" + "reference": "f9fdd372473e66469c6d32a4ed12efcffdea38c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f9fdd372473e66469c6d32a4ed12efcffdea38c4", + "reference": "f9fdd372473e66469c6d32a4ed12efcffdea38c4", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/polyfill-php83": "^1.29", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "amphp/amp": "<2.5", - "amphp/socket": "<1.1", - "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.4" + "amphp/amp": "<3", + "php-http/discovery": "<1.15" }, "provide": { "php-http/async-client-implementation": "*", @@ -3726,20 +4674,19 @@ "symfony/http-client-implementation": "3.0" }, "require-dev": { - "amphp/http-client": "^4.2.1|^5.0", - "amphp/http-tunnel": "^1.0|^2.0", + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", "guzzlehttp/promises": "^1.4|^2.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", - "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3770,7 +4717,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.5" + "source": "https://github.com/symfony/http-client/tree/v8.0.5" }, "funding": [ { @@ -3790,7 +4737,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-01-27T16:18:07+00:00" }, { "name": "symfony/http-client-contracts", @@ -3872,37 +4819,35 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", + "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^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-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3930,7 +4875,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.5" }, "funding": [ { @@ -3950,78 +4895,63 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-01-27T16:18:07+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20c1c5e41fc53928dbb670088f544f2d460d497d", + "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, "type": "library", "autoload": { @@ -4049,7 +4979,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.5" }, "funding": [ { @@ -4069,32 +4999,31 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-01-28T10:46:31+00:00" }, { "name": "symfony/intl", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "7fa2d46174166bcd7829abc8717949f8a0b21fb7" + "reference": "8d049269c2accca0b02e5f9de39f3ee92ebc4468" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/7fa2d46174166bcd7829abc8717949f8a0b21fb7", - "reference": "7fa2d46174166bcd7829abc8717949f8a0b21fb7", + "url": "https://api.github.com/repos/symfony/intl/zipball/8d049269c2accca0b02e5f9de39f3ee92ebc4468", + "reference": "8d049269c2accca0b02e5f9de39f3ee92ebc4468", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4" }, "conflict": { - "symfony/string": "<7.1" + "symfony/string": "<7.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4139,7 +5068,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v7.4.4" + "source": "https://github.com/symfony/intl/tree/v8.0.4" }, "funding": [ { @@ -4159,43 +5088,39 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "a074d353f5b5a81d356652e8a2034fdd0501420b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a074d353f5b5a81d356652e8a2034fdd0501420b", + "reference": "a074d353f5b5a81d356652e8a2034fdd0501420b", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", + "php": ">=8.4", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4223,7 +5148,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v8.0.4" }, "funding": [ { @@ -4243,7 +5168,7 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-01-08T08:40:07+00:00" }, { "name": "symfony/mcp-sdk", @@ -4321,40 +5246,37 @@ }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "6b767f21415bec1a247f5d1a4777986e24b05174" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/6b767f21415bec1a247f5d1a4777986e24b05174", + "reference": "6b767f21415bec1a247f5d1a4777986e24b05174", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4386,7 +5308,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v8.0.4" }, "funding": [ { @@ -4406,7 +5328,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-01-08T22:36:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4994,86 +5916,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "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\\Php83\\": "" - }, - "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.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.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": "2025-07-08T02:45:35+00:00" - }, { "name": "symfony/polyfill-php85", "version": "v1.33.0", @@ -5239,34 +6081,29 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/4a2bc08d1c35307239329f434d45c2bfe8241fa9", + "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "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" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5300,7 +6137,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v8.0.4" }, "funding": [ { @@ -5320,7 +6157,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "symfony/service-contracts", @@ -5411,35 +6248,34 @@ }, { "name": "symfony/string", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + "reference": "758b372d6882506821ed666032e43020c4f57194" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", "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" + "php": ">=8.4", + "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/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5478,7 +6314,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.4" + "source": "https://github.com/symfony/string/tree/v8.0.4" }, "funding": [ { @@ -5498,28 +6334,28 @@ "type": "tidelift" } ], - "time": "2026-01-12T10:54:30+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/8b81bd3700f5c1913c22a3266a647aa1bb974435", + "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5556,7 +6392,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v8.0.4" }, "funding": [ { @@ -5576,35 +6412,35 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-01-03T23:40:55+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82", + "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -5643,7 +6479,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.4" }, "funding": [ { @@ -5663,30 +6499,29 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-01-01T23:07:29+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5724,7 +6559,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" }, "funding": [ { @@ -5744,32 +6579,31 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:15:23+00:00" + "time": "2025-11-05T18:53:00+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -5800,7 +6634,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v8.0.1" }, "funding": [ { @@ -5820,7 +6654,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2025-12-04T18:17:06+00:00" }, { "name": "tecnickcom/tcpdf", @@ -6083,6 +6917,95 @@ } ], "time": "2026-01-23T21:00:41+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.3", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/690d4dd47b78f423cb90457f858e4106e1deb728", + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-18T14:27:35+00:00" } ], "packages-dev": [ @@ -6195,30 +7118,29 @@ }, { "name": "doctrine/instantiator", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.4" }, "require-dev": { - "doctrine/coding-standard": "^11", + "doctrine/coding-standard": "^14", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -6245,7 +7167,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" }, "funding": [ { @@ -6261,7 +7183,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2026-01-05T06:47:08+00:00" }, { "name": "mikey179/vfsstream", @@ -6546,16 +7468,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "2f5cbed597cb261d1ea458f3da3a9ad32e670b1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/2f5cbed597cb261d1ea458f3da3a9ad32e670b1e", + "reference": "2f5cbed597cb261d1ea458f3da3a9ad32e670b1e", "shasum": "" }, "require": { @@ -6563,8 +7485,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -6574,7 +7496,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -6604,44 +7527,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.1" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-01-20T15:30:42+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -6662,9 +7585,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -7218,6 +8141,68 @@ ], "time": "2026-02-10T12:32:02+00:00" }, + { + "name": "radebatz/type-info-extras", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/DerManoMann/type-info-extras.git", + "reference": "217e249a35dbdbd9537f99de622cc080c3f8fb2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/217e249a35dbdbd9537f99de622cc080c3f8fb2c", + "reference": "217e249a35dbdbd9537f99de622cc080c3f8fb2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpstan/phpdoc-parser": "^2.0", + "symfony/type-info": "^7.3.8 || ^7.4.1 || ^8.0 || ^8.1-@dev" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.70", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Radebatz\\TypeInfoExtras\\": "src" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Martin Rademacher", + "email": "mano@radebatz.org" + } + ], + "description": "Extras for symfony/type-info", + "homepage": "http://radebatz.net/mano/", + "keywords": [ + "component", + "symfony", + "type-info", + "types" + ], + "support": { + "issues": "https://github.com/DerManoMann/type-info-extras/issues", + "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.5" + }, + "time": "2026-02-07T00:19:33+00:00" + }, { "name": "rector/rector", "version": "2.3.6", @@ -8229,23 +9214,23 @@ }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -8273,7 +9258,89 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v8.0.5" + }, + "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-01-26T15:08:38+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.4" }, "funding": [ { @@ -8293,7 +9360,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-09T12:15:10+00:00" }, { "name": "theseer/tokenizer", @@ -8409,24 +9476,25 @@ }, { "name": "zircote/swagger-php", - "version": "5.8.2", + "version": "6.0.5", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "18ee6592518238a5a6c9905aae4926d5bbc65754" + "reference": "9a0a612584e7dea0e069b3b1a2120b404f3a51d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/18ee6592518238a5a6c9905aae4926d5bbc65754", - "reference": "18ee6592518238a5a6c9905aae4926d5bbc65754", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/9a0a612584e7dea0e069b3b1a2120b404f3a51d4", + "reference": "9a0a612584e7dea0e069b3b1a2120b404f3a51d4", "shasum": "" }, "require": { "ext-json": "*", "nikic/php-parser": "^4.19 || ^5.0", - "php": ">=7.4", + "php": ">=8.2", "phpstan/phpdoc-parser": "^2.0", "psr/log": "^1.1 || ^2.0 || ^3.0", + "radebatz/type-info-extras": "^1.0.2", "symfony/deprecation-contracts": "^2 || ^3", "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" @@ -8438,14 +9506,9 @@ "composer/package-versions-deprecated": "^1.11", "doctrine/annotations": "^2.0", "friendsofphp/php-cs-fixer": "^3.62.0", - "phpstan/phpstan": "^1.6 || ^2.0", - "phpunit/phpunit": "^9.0", - "rector/rector": "^1.0 || ^2.3.1", - "vimeo/psalm": "^4.30 || ^5.0" - }, - "suggest": { - "doctrine/annotations": "^2.0", - "radebatz/type-info-extras": "^1.0.2" + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.3.1" }, "bin": [ "bin/openapi" @@ -8453,7 +9516,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -8491,7 +9554,7 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.8.2" + "source": "https://github.com/zircote/swagger-php/tree/6.0.5" }, "funding": [ { @@ -8499,7 +9562,7 @@ "type": "github" } ], - "time": "2026-02-10T20:10:15+00:00" + "time": "2026-02-10T20:05:00+00:00" } ], "aliases": [], @@ -8510,12 +9573,14 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.3.0", + "php": ">=8.4.0", "ext-curl": "*", "ext-fileinfo": "*", "ext-filter": "*", "ext-gd": "*", "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", "ext-sodium": "*", "ext-xml": "*", "ext-xmlwriter": "*", @@ -8523,7 +9588,7 @@ }, "platform-dev": {}, "platform-overrides": { - "php": "8.3.0" + "php": "8.4.0" }, "plugin-api-version": "2.9.0" } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d44c3f2ab9..d111a0cf3d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -105,7 +105,7 @@ services: # PHP Production Settings - PHP_LOG_ERRORS=On - - PHP_ERROR_REPORTING=E_ALL & ~E_DEPRECATED & ~E_STRICT + - PHP_ERROR_REPORTING=E_ALL & ~E_DEPRECATED - PHP_DISPLAY_ERRORS=Off - PHP_DISPLAY_STARTUP_ERRORS=Off @@ -195,7 +195,7 @@ services: # - PMF_ENABLE_UPLOADS=${PMF_ENABLE_UPLOADS:-On} # - PMF_MEMORY_LIMIT=${PMF_MEMORY_LIMIT:-512M} # - PHP_LOG_ERRORS=On - # - PHP_ERROR_REPORTING=E_ALL & ~E_DEPRECATED & ~E_STRICT + # - PHP_ERROR_REPORTING=E_ALL & ~E_DEPRECATED # - PHP_DISPLAY_ERRORS=Off # volumes: # - phpmyfaq_data:/var/www/html @@ -235,7 +235,7 @@ services: # - PMF_ENABLE_UPLOADS=${PMF_ENABLE_UPLOADS:-On} # - PMF_MEMORY_LIMIT=${PMF_MEMORY_LIMIT:-512M} # - PHP_LOG_ERRORS=On - # - PHP_ERROR_REPORTING=E_ALL & ~E_DEPRECATED & ~E_STRICT + # - PHP_ERROR_REPORTING=E_ALL & ~E_DEPRECATED # - PHP_DISPLAY_ERRORS=Off # - SERVER_NAME=${SERVER_NAME:-:80} # volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 7b43d31540..4abeb2c225 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,15 @@ services: volumes: - ./volumes/postgres:/var/lib/postgresql/data + redis: + image: redis:7-alpine + restart: always + command: redis-server --appendonly yes + ports: + - '6379:6379' + volumes: + - ./volumes/redis:/data + #sqlserver: # image: mcr.microsoft.com/mssql/server:2022-latest # ports: @@ -37,6 +46,7 @@ services: # ACCEPT_EULA: 'Y' apache: + profiles: ['apache'] build: context: . dockerfile: .docker/apache/Dockerfile @@ -59,6 +69,7 @@ services: links: - mariadb:db - postgres + - redis - elasticsearch - opensearch ports: @@ -106,6 +117,7 @@ services: links: - mariadb:db - postgres + - redis - elasticsearch - opensearch volumes: @@ -114,6 +126,7 @@ services: - pnpm frankenphp: + profiles: ['frankenphp'] build: .docker/frankenphp restart: always stdin_open: true @@ -134,6 +147,7 @@ services: links: - mariadb:db - postgres + - redis - elasticsearch - opensearch ports: @@ -150,7 +164,7 @@ services: - pnpm pnpm: - image: node:22-alpine + image: node:24-alpine restart: 'no' command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm build" working_dir: /app @@ -197,7 +211,7 @@ services: elasticsearch: image: elasticsearch:8.16.5 - container_name: phpmyfaq-41_elasticsearch-v8 + container_name: phpmyfaq-42_elasticsearch-v8 restart: always environment: - cluster.name=phpmyfaq-cluster @@ -228,7 +242,7 @@ services: opensearch: image: opensearchproject/opensearch:2.19.2 - container_name: phpmyfaq-41_opensearch + container_name: phpmyfaq-42_opensearch environment: - cluster.name=phpmyfaq-cluster - node.name=phpmyfaq diff --git a/docs/administration.md b/docs/administration.md index 20bc5cb1d5..f0005d440b 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -79,7 +79,11 @@ For SEO reasons (reduced file size), you may want to **display the exact same file** as a background image **multiple times**. To do this, enter the same file name for each entry in your **database, "faqcategories" table, image field**. -### 5.2.2 FAQ Administration +### 5.2.2 Add a new FAQ + +You can create a completely new FAQ by using the 'Add new FAQ' option. When doing so, it is essential to select the desired category within the 'FAQ metadata' tab to ensure the entry is correctly indexed and visible to users. For a detailed explanation of all available settings and metadata options, please refer to section 5.2.3. + +### 5.2.3 FAQ Administration You can create entries directly in the admin area. Created entries aren't published by default. All available FAQs are listed on the page "Edit FAQs". By clicking on them, the same interface that lets you create records will open up, this @@ -175,32 +179,45 @@ phpMyFAQ lets visitors contribute to the FAQ by asking questions. Every visitor the public area and may give an answer. If you wish to get rid of open questions, you can do so using this section. Alternatively, you can take over a question and answer it yourself and hereby add it to the FAQ. -### 5.2.3 Sticky FAQs +### 5.2.4 Sticky FAQs You can arrange the order of the sticky FAQs by drag'n'drop. The order of the sticky FAQs will be the same in the public frontend. +To remove the "Important / Sticky" status from an FAQ and remove it from the overview, simply use the pin icon on the right. +Only the status will be changed - the FAQ will not be deleted from the database. -### 5.2.4 Comment Administration +### 5.2.5 Orphaned FAQs -In this frontend, you can see all comments that'd been posted in the FAQs and the news. You can't edit comments, -but you can delete them with one easy click. +Orphaned FAQs are records that are no longer assigned to any category. This typically happens when categories are deleted without moving the associated FAQs, or through incomplete data imports. As they lack a category assignment, these entries are hidden from users in the public frontend. -### 5.2.5 Open Questions +Cross-Language Management +Administrators can manage orphaned FAQs across all installed languages, without needing to switch their backend interface language. The list displays all orphaned entries in the system, including the full language name, and allows direct editing within the correct linguistic context. -On the "Open Questions" page, you can see all open questions that visitors have posted. -You can answer them directly or, if they are not visible due to your configuration in the public area, you can activate -them. -Additionally, you can delete them, too. +How to Resolve Orphaned FAQs: +To correct an orphaned FAQ entry, you must assign it to a valid category in the corresponding language: -### 5.2.6 Glossary +1. Open the FAQ: The orphaned FAQ list displays entries from all languages. Click on the desired entry to open the FAQ editor, which will automatically be set to the FAQ's language. -A glossary is a list of terms in a particular domain of knowledge with the definitions for those terms. You can add, -edit, and delete glossary items here. The items will be automatically displayed in tags in the frontend. +2. Assign Category: Navigate to the "FAQ metadata" tab within the editor. The available categories in the dropdown list will automatically match the language of the FAQ you are editing, ensuring you can only select a category appropriate for that language. -### 5.2.7 News Administration +3. Save Changes: Select a valid category and save the FAQ. -phpMyFAQ offers the ability to post news on the starting page of your FAQ. -In the administration area, you can create new news, edit existing news, or delete them. +Once saved, the entry will be visible in its new category and will automatically be removed from the Orphaned FAQ list. + +### 5.2.6 Open Questions + +On the "Open Questions" page, you can see all open questions that visitors have posted in the currently selected administration language. +You can answer open questions directly or, if they are not visible in the public area due to visibility settings, you can activate them. +Additionally, you can delete them. + +Please note: +Questions awaiting moderation in other languages are not automatically shown here. If questions exist in other active languages, a warning alert will be displayed at the bottom of the page indicating which languages have pending items and the respective counts. +To process these other questions, you must change your current administration language using the language selector in the top menu. + +### 5.2.7 Comment Administration + +In this frontend, you can see all comments that have been posted in the FAQs and the news. You can't edit comments, +but you can delete them with one easy click. ### 5.2.8 Attachment Administration @@ -212,6 +229,359 @@ You can delete them, too. You can edit existing tags, and if you need to, you can delete the tag. +### 5.2.10 Glossary + +A glossary is a list of terms in a particular domain of knowledge with the definitions for those terms. You can add, +edit, and delete glossary items here. The items will be automatically displayed in tags in the frontend. + +### 5.2.11 News Administration + +phpMyFAQ offers the ability to post news on the starting page of your FAQ. +In the administration area, you can create new news, edit existing news, or delete them. + +### 5.2.12 Custom Pages Administration + +Custom Pages allow you to create database-backed, SEO-friendly pages for legal information, about pages, and other +static content using a WYSIWYG editor. Custom pages support multi-language content, are automatically included in +sitemaps, and are searchable alongside FAQs. + +#### 5.2.12.1 Creating a Custom Page + +To create a new custom page: + +1. Navigate to **Content → Custom Pages** in the admin menu +2. Click the **Add new page** button +3. Fill in the required information across three tabs: + +**Content Tab:** +- **Page Title**: The main title of your page (e.g., "Privacy Policy") +- **URL Slug**: SEO-friendly URL identifier (e.g., "privacy-policy") + - Automatically generated from the title + - Must be unique per language + - Real-time validation shows availability + - Results in URL: `https://example.com/page/privacy-policy.html` +- **Content**: Rich text editor (TinyMCE) for page content + - Full WYSIWYG editing with formatting, images, links + - No HTML knowledge required + - Images can be uploaded and managed + +**SEO Tab:** +- **SEO Title**: Custom title for search engines (max 60 characters) + - Character counter helps optimize length + - Falls back to the page title if empty +- **Meta-Description**: Description for search results (max 160 characters) + - Character counter helps optimize length +- **Robots Directive**: Controls search engine indexing + - `index, follow` (default): Allow indexing and following links + - `noindex, follow`: Don't index but follow links + - `index, nofollow`: Index but don't follow links + - `noindex, nofollow`: Don't index and don't follow links + +**Settings Tab:** +- **Language**: Select the page language (supports multi-language content) +- **Author Name**: Name of the content author +- **Author Email**: Email of the content author +- **Active**: Toggle to publish/unpublish the page + - Only active pages appear in search results and sitemaps + - Inactive pages return 404 errors when accessed + +4. Click **Save** to create the page + +#### 5.2.12.2 Managing Custom Pages + +The Custom Pages list view provides: + +- **Pagination**: Navigate through pages with configurable items per page +- **Sorting**: Click column headers to sort by title, slug, language, or date +- **Filtering**: Filter by language or active status +- **Actions**: + - **Edit**: Modify existing pages + - **Delete**: Remove pages (with confirmation) + - **Active Toggle**: Quickly publish/unpublish pages + +#### 5.2.12.3 Multi-Language Support + +Custom pages support multiple languages: + +- Create the same page in different languages using the same ID +- Each language version has its own slug +- Language is selected from the Settings tab +- Example: "Privacy Policy" can exist as: + - English: `privacy-policy` + - German: `datenschutz` + - French: `politique-de-confidentialite` + +#### 5.2.12.4 Legal Pages Integration + +Custom pages can be used for legal pages with automatic footer link integration: + +**Configuration Setup:** + +Navigate to **Configuration → Main Settings** and configure: + +- **Privacy URL** (`main.privacyURL`) +- **Terms of Service URL** (`main.termsURL`) +- **Imprint URL** (`main.imprintURL`) +- **Cookie Policy URL** (`main.cookiePolicyURL`) + +**Two Configuration Options:** + +1. **Custom Page Reference**: Use format `page:slug` + - Example: `page:privacy-policy` + - Redirects `/privacy.html` → `/page/privacy-policy.html` + - Recommended for database-backed legal pages + +2. **External URL**: Use full URL + - Example: `https://example.com/legal/privacy` + - Redirects to external website + - Useful if legal pages are hosted elsewhere + +**Footer Links:** + +When configured, links automatically appear in the footer: +- Privacy Statement (if `main.privacyURL` is set) +- Terms of Service (if `main.termsURL` is set) +- Imprint (if `main.imprintURL` is set) +- Cookie Policy (if `main.cookiePolicyURL` is set) + +**Example Configuration:** + +``` +main.privacyURL = page:privacy-policy +main.termsURL = page:terms-of-service +main.imprintURL = page:imprint +main.cookiePolicyURL = page:cookie-policy +``` + +#### 5.2.12.5 Search Integration + +Custom pages are automatically integrated with all search engines: + +**Database Search:** +- Uses LIKE queries on title and content +- Works without additional configuration +- Searches alongside FAQs + +**Elasticsearch Integration:** +- Automatically indexed with `content_type='page'` +- Updated in real-time on create/update/delete +- Priority 0.80 in search results +- Bulk import via **Elasticsearch → Import Data** + +**OpenSearch Integration:** +- Same functionality as Elasticsearch +- Automatic real-time indexing +- Bulk import via **OpenSearch → Import Data** + +**Search Results:** +- Custom pages appear with a file-text icon (📄) +- FAQs appear with a question-circle icon (❓) +- Results link directly to `/page/{slug}.html` + +#### 5.2.12.6 Sitemap Integration + +Active custom pages are automatically included in XML sitemaps: + +- Only active pages (`active='y'`) are included +- Priority: 0.80 (FAQs have 1.00) +- Last modified date from `updated` or `created` field +- URL format: `https://example.com/page/{slug}.html` +- No manual configuration needed + +Access sitemap at: +- `https://example.com/sitemap.xml` +- `https://example.com/sitemap.xml.gz` (gzipped) + +#### 5.2.12.7 SEO Best Practices + +**Slug Guidelines:** +- Use lowercase letters, numbers, and hyphens only +- Keep short and descriptive (e.g., `about-us`, `privacy-policy`) +- Avoid special characters or spaces +- Make it meaningful for users and search engines + +**Content Guidelines:** +- Write clear, concise content focused on user needs +- Use headings (H2, H3) to structure content +- Keep paragraphs short for readability +- Include relevant keywords naturally +- Update regularly to keep content current + +**SEO Optimization:** +- Fill in SEO title (under 60 characters) +- Write compelling meta description (under 160 characters) +- Use appropriate robots directive +- Set pages as active when ready to publish +- Use descriptive page titles + +#### 5.2.12.8 Permissions + +Custom pages require the following permissions: + +- **PAGE_ADD**: Create new custom pages +- **PAGE_EDIT**: Modify existing pages +- **PAGE_DELETE**: Delete pages + +Permissions are granted via **User Administration** → **Edit User** → **Permissions**. + +Super admins have all permissions by default. + +#### 5.2.12.9 Troubleshooting + +**Slug validation fails:** +- Ensure slug is unique per language +- Check for special characters (only lowercase, numbers, hyphens allowed) +- Try a different slug + +**Page not appearing in search:** +- Verify page is set to active +- Re-index search engine (Elasticsearch/OpenSearch → Drop Index → Create Index → Import Data) +- Check search configuration is enabled + +**404 error when accessing the page:** +- Verify the page is active +- Check slug matches URL exactly +- Ensure language matches current site language + +**Footer links do not appear:** +- Verify configuration values are set correctly +- Use format `page:slug` or full URL +- Check page exists and is active +- Clear cache if using caching + +### 5.2.13 AI-Assisted Translation + +phpMyFAQ includes an AI-assisted translation feature that helps you translate FAQ content, custom pages, categories, and +news articles into multiple languages using professional translation APIs. The feature preserves HTML formatting and +provides high-quality automated translations. + +#### 5.2.13.1 Overview + +The AI translation feature integrates with leading translation services: + +- **Google Cloud Translation** - Neural machine translation with 100+ languages +- **DeepL** - Premium quality translations, especially for European languages +- **Azure Translator** - Microsoft's translation service with generous free tier +- **Amazon Translate** - AWS translation service with 75+ languages +- **LibreTranslate** - Open-source, self-hosted option for privacy + +#### 5.2.13.2 Configuration + +Navigate to **Configuration → Translation** tab to configure your translation provider: + +1. Select your preferred **Translation Provider** from the dropdown +2. Enter the required API credentials for your chosen provider +3. Click **Save Configuration** to activate + +**Provider-Specific Credentials:** + +- **Google**: API key from Google Cloud Console +- **DeepL**: API key from DeepL dashboard (Free or Pro) +- **Azure**: API key and region from Azure Portal +- **Amazon**: AWS Access Key ID, Secret Access Key, and region +- **LibreTranslate**: Server URL and optional API key + +For detailed setup instructions for each provider, see the [AI Translation Guide](ai-translation.md) and +[Quick Start Guide](ai-translation-quickstart.md). + +#### 5.2.13.3 Translating Content + +**Translating FAQs:** + +1. Navigate to **FAQs** and select an FAQ to translate +2. Click on the **Translation** tab or **Translate FAQ** +3. Select the target language from the dropdown +4. Click the **"Translate with AI"** button +5. Review the translated question, answer, and keywords +6. Make any necessary edits +7. Click **Save** to store the translation + +The AI will translate: +- Question text +- Answer (HTML formatting preserved) +- Keywords/tags + +**Translating Custom Pages:** + +1. Navigate to **Content → Custom Pages** +2. Select a page and click **Translate** +3. Select the target language +4. Click **"Translate with AI"** +5. Review translated content (title, content, SEO fields) +6. Adjust settings in the **Settings** tab +7. Click **Save** + +The AI will translate: +- Page title +- Page content (HTML formatting preserved) +- SEO title +- SEO description + +**Translating Categories:** + +1. Navigate to **Categories** +2. Select a category and click **Translate Category** +3. Select the target language +4. Click **"Translate with AI"** +5. Review the translated name and description +6. Click **Save** + +**Translating News:** + +1. Create or edit a news article +2. Use the translation interface to create language versions +3. The AI assists with translating headline and content + +#### 5.2.13.4 Best Practices + +**Review All Translations:** +- AI translation is very accurate but not perfect +- Always review technical terms, brand names, and legal content +- Edit translations before publishing to ensure quality + +**Maintain Consistency:** +- Use the same translation provider across your site +- Keep a glossary of key terms and their preferred translations +- Use consistent terminology in source content + +**HTML Formatting:** +- Simple HTML (bold, italic, links, lists) translates best +- Complex nested structures may need manual adjustment +- Always preview translated content before publishing + +**Cost Management:** +- Start with free tiers (DeepL Free: 500k chars/month, Azure: 2M chars/month) +- Monitor usage in your provider's dashboard +- Don't re-translate unnecessarily - review and edit instead + +#### 5.2.13.5 Troubleshooting + +**Translation button is disabled:** +- Verify translation provider is configured in settings +- Ensure source and target languages are different +- Check that both languages are supported by your provider + +**Translation fails with error:** +- Verify API credentials are correct +- Check you haven't exceeded free tier limits +- For Azure: ensure region format is correct (e.g., "eastus" not "East US") + +**Poor translation quality:** +- Try DeepL for better quality (European languages) +- Simplify source text (shorter sentences, clear language) +- Review and edit translations manually +- Verify language is well-supported by your provider + +**HTML formatting issues:** +- Ensure source content has valid, clean HTML +- Simplify complex HTML structures +- Preview before saving +- Re-translate if formatting is broken + +For comprehensive documentation, see: +- [Complete AI Translation Guide](ai-translation.md) - Full documentation +- [Quick Start Guide](ai-translation-quickstart.md) - Get started in 5 minutes + ## 5.3 Statistics ### 5.3.1 Ratings @@ -405,6 +775,27 @@ To back up the whole data located on your web server, you can run our simple bac Here you can edit the general, FAQ specific, search, spam protection, spam control center, SEO related, layout settings, Mail setup for SMTP, API settings, online update settings, and if enabled, LDAP configuration of phpMyFAQ. +Mail delivery supports multiple providers: + +- `smtp` (default; supports per-tenant SMTP settings) +- `sendgrid` +- `ses` + +Configuration keys: + +- `mail.provider` +- `mail.useQueue` +- `mail.sendgridApiKey` +- `mail.sesAccessKeyId` +- `mail.sesSecretAccessKey` +- `mail.sesRegion` + +Outgoing emails are queued by default (queue `mail`) and delivered by the background worker: + +```bash +php bin/worker.php +``` + ### 5.6.2 FAQ Multi-sites You can see a list of all multisite installations, and you're able to add new ones. diff --git a/docs/ai-translation.md b/docs/ai-translation.md new file mode 100644 index 0000000000..c28387cbb2 --- /dev/null +++ b/docs/ai-translation.md @@ -0,0 +1,366 @@ +# 9. AI-Assisted Translation Feature + +phpMyFAQ includes an AI-assisted translation feature that helps you translate your FAQ content, custom pages, +categories, and news articles into multiple languages using professional translation services. + +## 9.1 Overview + +The translation feature integrates with leading translation APIs to provide high-quality, automated translations while +preserving HTML formatting in your content. This saves time when creating multilingual content and ensures consistency +across different language versions of your FAQ. + +## 9.2 Supported Translation Providers + +phpMyFAQ supports five translation providers: + +1. **Google Cloud Translation** - Google's neural machine translation service +2. **DeepL** – Known for high-quality, natural-sounding translations +3. **Azure Translator** – Microsoft's translation service +4. **Amazon Translate** - AWS translation service +5. **LibreTranslate** - Open-source, self-hosted option + +Each provider has different pricing models, language support, and quality characteristics. Choose the one that best +fits your needs and budget. + +## 9.3 Configuration + +### 9.3.1 Accessing Translation Settings + +1. Log in to the phpMyFAQ admin panel +2. Navigate to **Configuration** → **Translation** tab +3. Select your preferred translation provider from the dropdown +4. Enter the required API credentials +5. Click **Save Configuration** + +### 9.3.2 Provider-Specific Setup + +#### 9.3.2.1 Google Cloud Translation + +**Prerequisites:** +- A Google Cloud Platform account +- Billing enabled on your GCP project +- Cloud Translation API enabled + +**Setup Steps:** + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) +2. Create a new project or select an existing one +3. Navigate to **APIs & Services** → **Library** +4. Search for "Cloud Translation API" and enable it +5. Go to **APIs & Services** → **Credentials** +6. Click **Create Credentials** → **API Key** +7. Copy the API key +8. In phpMyFAQ admin, set: + - **Provider**: Google Cloud Translation + - **Google Cloud Translation API key**: Paste your API key + +**Pricing:** Pay-as-you-go, typically $20 per million characters +**Languages:** 100+ languages supported + +#### 9.3.2.2 DeepL + +**Prerequisites:** +- A DeepL account (Free or Pro) +- DeepL API key + +**Setup Steps:** + +1. Sign up at [DeepL API](https://www.deepl.com/pro-api) +2. Choose between Free or Pro plan: + - **Free**: Up to 500,000 characters/month + - **Pro**: Pay-as-you-go, higher limits +3. Get your API key from the account settings +4. In phpMyFAQ admin, set: + - **Provider**: DeepL + - **DeepL API key**: Paste your API key + - **Use DeepL Free API**: Check if using Free plan, uncheck for Pro + +**Pricing:** +- Free: €0 for up to 500,000 characters/month +- Pro: ~€20 per million characters + +**Languages:** 30+ languages (fewer than Google, but higher quality) + +#### 9.3.2.3 Azure Translator + +**Prerequisites:** +- Microsoft Azure account +- Azure Translator resource created + +**Setup Steps:** + +1. Sign in to the [Azure Portal](https://portal.azure.com) +2. Click **Create a resource** → Search for "Translator" +3. Click **Create** and configure: + - Select your subscription and resource group + - Choose a region (e.g., East US, West Europe) + - Select a pricing tier (F0 for free tier) +4. After deployment, go to your Translator resource +5. Navigate to **Keys and Endpoint** +6. Copy **Key 1** and note the **Region** +7. In phpMyFAQ admin, set: + - **Provider**: Azure Translator + - **Azure Translator API key**: Paste Key 1 + - **Azure region**: Enter your region (e.g., eastus, westeurope) + +**Pricing:** +- Free tier: 2 million characters/month +- Standard: ~$10 per million characters + +**Languages:** 90+ languages supported + +#### 9.3.2.4 Amazon Translate + +**Prerequisites:** +- AWS account +- IAM user with Amazon Translate permissions + +**Setup Steps:** + +1. Sign in to the [AWS Console](https://console.aws.amazon.com) +2. Navigate to **IAM** → **Users** +3. Create a new user or select an existing one +4. Attach the policy **TranslateFullAccess** (or create a custom policy with `translate:TranslateText` permission) +5. Go to **the Security credentials** tab +6. Click **Create access key** +7. Choose **Third-party service** and create the key +8. Copy the **Access Key ID** and **Secret Access Key** +9. In phpMyFAQ admin, set: + - **Provider**: Amazon Translate + - **Amazon Translate AWS Access Key ID**: Paste access key ID + - **Amazon Translate AWS Secret Access Key**: Paste secret key + - **Amazon Translate AWS region**: Enter region (e.g., us-east-1, eu-west-1) + +**Pricing:** Pay-as-you-go, typically $15 per million characters +**Languages:** 75+ languages supported + +#### 9.3.2.5 LibreTranslate (Self-Hosted) + +**Prerequisites:** +- A server to host LibreTranslate (optional – can use public instance) +- Docker or Python environment + +**Setup Steps:** + +**Option 1: Use Public Instance** +1. In phpMyFAQ admin, set: + - **Provider**: LibreTranslate + - **LibreTranslate server URL**: `https://libretranslate.com` + - **LibreTranslate API key**: Leave empty (or get a free API key from libretranslate.com) + +**Option 2: Self-Host** +1. Install LibreTranslate on your server: + ```bash + # Using Docker + docker run -d -p 5000:5000 libretranslate/libretranslate + + # Or using Python + pip install libretranslate + libretranslate + ``` +2. In phpMyFAQ admin, set: + - **Provider**: LibreTranslate + - **LibreTranslate server URL**: Your server URL (e.g., `http://your-server:5000`) + - **LibreTranslate API key**: Leave empty or set if you configured API key protection + +**Pricing:** Free if self-hosted, or usage limits on public instance +**Languages:** 30+ languages supported + +## 9.4 Using the Translation Feature + +### 9.4.1 Translating FAQs + +1. Navigate to **FAQs** → Edit an existing FAQ +2. In the FAQ editor, switch to the **Translation** tab (or click **Translate FAQ**) +3. Select the **target language** from the dropdown +4. The original language will be auto-detected +5. Click the **"Translate with AI"** button +6. The system will automatically translate: + - Question text + - Answer (preserving HTML formatting like bold, links, lists) + - Keywords +7. Review the translated content +8. Make any necessary edits or adjustments +9. Click **Save** to save the translation + +**Important:** HTML tags in the answer (like `

`, ``, ``, etc.) are automatically preserved during +translation. + +### 9.4.2 Translating Custom Pages + +1. Navigate to **Content** → **Custom Pages** +2. Select a page and click **Translate** +3. Select the **target language** +4. Click **"Translate with AI"** button +5. The system will translate: + - Page title + - Page content (HTML preserved) + - SEO title (meta title) + - SEO description (meta description) +6. Review and edit the translations +7. Configure the language and other settings in the **Settings** tab +8. Click **Save** to add the translation + +### 9.4.3 Translating Categories + +1. Navigate to **Categorize** +2. Select a category and click **Translate Category** +3. Select the **target language** +4. Click **"Translate with AI"** button +5. The system will translate: + - Category name + - Category description +6. Review the translations +7. Click **Save** to add the translation + +### 9.4.4 Translating News Articles + +Currently, news articles use a similar workflow: +1. Create or edit a news article +2. Use the translation interface to create language versions +3. The AI can assist with translating the headline and content + +## 9.5 Best Practices + +### 9.5.1 Review All Translations + +AI translation is very good, but not perfect. Always review and edit translations before publishing, especially for: +- Technical terms specific to your domain +- Brand names and product names +- Legal or compliance-related content +- Idiomatic expressions + +### 9.5.2 Maintain Consistency + +- Use the same translation provider across your site for consistency +- Create a glossary of key terms and their preferred translations +- Use the same terminology in source content to get consistent translations + +### 9.5.3 HTML Formatting + +- The AI preserves HTML tags, but complex nested structures may occasionally need adjustment +- Always preview translated content to ensure formatting is correct +- Simple formatting (bold, italic, links, lists) works best + +### 9.5.4 Language Selection + +Consider language support when choosing a provider: +- **Most languages**: Google Cloud Translation (100+) +- **European languages (highest quality)**: DeepL +- **Enterprise requirements**: Azure Translator or Amazon Translate +- **Privacy/self-hosted**: LibreTranslate + +### 9.5.5 Cost Management + +Monitor your translation usage to control costs: +- Start with free tiers when available (Azure, DeepL Free, LibreTranslate) +- Translate in batches to stay organized +- Review auto-translated content rather than re-translating +- Consider caching translations for frequently updated content + +## 9.6 Workflow Recommendations + +### 9.6.1 Initial Setup +1. Choose your translation provider based on languages needed and budget +2. Configure API credentials in phpMyFAQ admin +3. Test with a single FAQ to verify the integration works +4. Review quality and adjust provider if needed + +### 9.6.2 Creating Multilingual Content +1. **Write in your primary language first** – Create high-quality source content +2. **Translate one language at a time** – Easier to review and maintain consistency +3. **Use AI as first draft** – Let AI do the initial translation +4. **Review and refine** – Edit translations for accuracy and tone +5. **Keep active** – Set translations to active/inactive to control publishing + +### 9.6.3 Updating Content +1. Update the primary language version first +2. Mark translations that need updating +3. Re-translate or manually update other languages +4. Review changes before publishing + +## 9.7 Troubleshooting + +### 9.7.1 Translation button is disabled +- **Check provider configuration**: Ensure you've selected a provider and entered valid credentials +- **Check language selection**: Source and target languages must be different +- **Check language support**: Ensure both languages are supported by your chosen provider + +### 9.7.2 Translation fails or returns errors +- **API credentials**: Verify your API key/credentials are correct and active +- **API quota**: Check if you've exceeded your free tier or quota limits +- **Network issues**: Ensure your server can reach the translation API +- **Content length**: Very long content may need to be translated in smaller chunks + +### 9.7.3 Poor translation quality +- **Try a different provider**: DeepL often has better quality for European languages +- **Review source content**: Complex or unclear source content leads to poor translations +- **Check language support**: Some language pairs work better than others +- **Edit after translation**: Use AI as a starting point, then refine manually + +### 9.7.4 HTML formatting issues +- **Check source HTML**: Ensure source content has valid, clean HTML +- **Simplify complex structures**: Very complex HTML may need manual adjustment +- **Preview before saving**: Always preview translated content +- **Re-translate if needed**: If formatting is broken, try translating again + +### 9.7.5 Cost concerns +- **Monitor usage**: Check your provider's dashboard for usage statistics +- **Use free tiers**: Start with free options (Azure, DeepL Free, LibreTranslate) +- **Set up billing alerts**: Configure alerts in your cloud provider console +- **Optimize workflow**: Translate only when needed, not repeatedly + +## 9.8 Security and Privacy + +### 9.8.1 Data Handling +- Translation content is sent to third-party APIs (except LibreTranslate self-hosted) +- Consider data privacy regulations (GDPR, etc.) when choosing a provider +- For sensitive content, consider self-hosted LibreTranslate +- API credentials are stored encrypted in the database + +### 9.8.2 API Security +- Keep API keys secure and never share them +- Use environment-specific keys (dev vs. production) +- Rotate keys periodically +- Monitor API usage for anomalies +- Consider IP restrictions where available + +## 9.9 Supported Languages by Provider + +### Google Cloud Translation +100+ languages including Arabic, Chinese (Simplified & Traditional), Czech, Danish, Dutch, English, Finnish, French, +German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Norwegian, Polish, Portuguese, Romanian, +Russian, Spanish, Swedish, Thai, Turkish, Ukrainian, Vietnamese, and many more. + +### DeepL +30+ languages including Bulgarian, Chinese (Simplified), Czech, Danish, Dutch, English (US & UK), Estonian, Finnish, +French, German, Greek, Hungarian, Indonesian, Italian, Japanese, Korean, Latvian, Lithuanian, Norwegian, Polish, +Portuguese (Brazilian & European), Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Turkish, Ukrainian. + +### Azure Translator +90+ languages including all major world languages and many regional variants. + +### Amazon Translate +75+ languages including all major world languages and many Asian, Middle Eastern, and African languages. + +### LibreTranslate +30+ languages including Arabic, Chinese, Czech, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, +Hungarian, Indonesian, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish, Ukrainian. + +## Support and Resources + +### Provider Documentation +- [Google Cloud Translation](https://cloud.google.com/translate/docs) +- [DeepL API](https://www.deepl.com/docs-api) +- [Azure Translator](https://docs.microsoft.com/azure/cognitive-services/translator/) +- [Amazon Translate](https://docs.aws.amazon.com/translate/) +- [LibreTranslate](https://libretranslate.com/) + +### Getting Help +- phpMyFAQ Documentation: https://www.phpmyfaq.de/docs +- phpMyFAQ Forum: https://forum.phpmyfaq.de +- GitHub Issues: https://github.com/thorsten/phpMyFAQ/issues + +### Contributing +If you find issues with the translation feature or have suggestions for improvements, please report them on our GitHub +repository. diff --git a/docs/development.md b/docs/development.md index f55030d8ec..81efda476e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ -# 8. Developer documentation +# 9. Developer documentation -## 8.1 Customizing phpMyFAQ +## 9.1 Customizing phpMyFAQ phpMyFAQ users have even more customization opportunities. The key feature is the user-selectable template sets, there is a templates/default directory where the default layouts get shipped. @@ -8,7 +8,7 @@ is a templates/default directory where the default layouts get shipped. In phpMyFAQ code and layout are separated. The layout is based on several template files, that you can modify to suit your own needs. All files for phpMyFAQ's default layout can be found in the directory _assets/templates/default/_. -### 8.1.1 Creating a custom layout +### 9.1.1 Creating a custom layout Follow these steps to create a custom template set: @@ -20,7 +20,7 @@ Follow these steps to create a custom template set: **Note:** There is a magic variable _{{ tplSetName }}_ containing the name of the actual layout available in each template file. -### 8.1.2 Debug Configuration +### 9.1.2 Debug Configuration phpMyFAQ 4.1 and later uses environment variables for the application configuration. @@ -62,7 +62,7 @@ Create a `.env` file in the project root based on `.env.example`: cp .env.example .env -### 8.1.3 Adding new language strings and translations +### 9.1.3 Adding new language strings and translations Please add at least the English translation for your new language string, the translation files are located in the folder **phpmyfaq/translations**. @@ -88,16 +88,16 @@ $PMF_LANG['direction'] = 'ltr'; // Text direction, 'ltr' for left-to-right, $PMF_LANG['nplurals'] = '2'; // Number of plural forms, e.g., '2' for English ``` -## 8.2 Templating +## 9.2 Templating -### 8.2.1 Introduction +### 9.2.1 Introduction phpMyFAQ v4 and later uses Twig, a modern template engine for PHP. It is fast, secure, and flexible. This documentation provides an overview of how to use Twig in phpMyFAQ. The default layout of phpMyFAQ is saved in the **assets/templates/default/index.twig** file. -### 8.2.2 Template files +### 9.2.2 Template files #### Variables @@ -160,13 +160,56 @@ You can then use the `dump` function in your templates. For more detailed information, visit the [Twig documentation](https://twig.symfony.com/doc/). -### 8.2.2 Admin backend templates +### 9.2.3 Admin backend templates The admin backend templates are located in the **assets/templates/admin** directory. Usually, you don't need to modify these templates, but if you want to, you can do so. Please be aware that changes to the admin backend templates can break the functionality of phpMyFAQ. -## 8.3 Themes +### 9.2.4 Dynamic template set and theme upload + +phpMyFAQ v4.2 introduced dynamic template set loading and a storage-backed `ThemeManager`. + +- Runtime template set is read from configuration key `layout.templateSet`. +- `TwigWrapper` now validates the requested set and falls back to `default` if the + configured set is invalid or missing on disk. +- Tenant provisioning can skip physical template copies and only persist the + template reference in `faqconfig` (`layout.templateSet`). + +Theme uploads use `phpMyFAQ\Template\ThemeManager`: + +- Input format: ZIP archive. +- Required file: `index.twig` (theme root or `/index.twig`). +- Allowed file extensions: + `.twig`, `.css`, `.js`, `.json`, `.png`, `.jpg`, `.jpeg`, `.svg`, `.webp`, `.gif`, + `.woff`, `.woff2`, `.ttf`, `.otf`. +- Upload target: `StorageInterface` at `//...` + (default root path: `themes`). +- Activation: `layout.templateSet` is updated via `ThemeManager::activateTheme()`. + +Example usage: + +```php +use phpMyFAQ\Template\ThemeManager; + +$themeManager = new ThemeManager($configuration, $storage, 'themes'); +$themeManager->uploadTheme('tenant-theme', '/tmp/tenant-theme.zip'); +$themeManager->activateTheme('tenant-theme'); +``` + +Admin UI: + +- Open `/admin/configuration` +- Use the `layout` tab +- Upload ZIP in the `layout` tab +- Select the template in `layout.templateSet` and save configuration + +Security checks: + +- Requires `CONFIGURATION_EDIT` permission +- CSRF token (`theme-manager`) is required for upload/activation + +## 9.3 Themes The default CSS theme is located in the **assets/templates/default** directory and is stored in the file **theme.css**. You can create your own CSS theme by copying the default theme and modifying it to suit your needs. @@ -210,14 +253,14 @@ phpMyFAQ uses Bootstrap’s color mode architecture. You can target specific mod For more information, check out the documentation on [Bootstrap](https://getbootstrap.com/docs/5.3/customize/css-variables/). -## 8.4 Custom CSS +## 9.4 Custom CSS You can add custom CSS to your phpMyFAQ installation by adding the CSS code in the admin configuration in the layout tab. This way, you can customize the look and feel of your phpMyFAQ installation, and you don't want to modify the SCSS files. -## 8.5 REST APIs +## 9.5 REST APIs phpMyFAQ offers interfaces to access phpMyFAQ installations with other clients like the iPhone App. phpMyFAQ includes a REST API and offers APIs for various services like fetching the phpMyFAQ version or doing a search against the @@ -225,7 +268,7 @@ phpMyFAQ installation. The API documentation can be found at [https://api-docs.phpmyfaq.de/](https://api-docs.phpmyfaq.de/). -## 8.6 phpMyFAQ development +## 9.6 phpMyFAQ development Since phpMyFAQ is an Open Source project, we encourage developers to contribute patches and code for us to include in the main package of phpMyFAQ. @@ -239,25 +282,25 @@ These basic rules make it possible for us to earn a living of the phpMyFAQ proje remains Open Source and under the MPL 2.0 license. All contributions will be added to the changelog and on the phpMyFAQ website. -### 8.6.1 How to contribute? +### 9.6.1 How to contribute? Contributing to phpMyFAQ is quite easy: just fork the [project on GitHub](https://github.com/thorsten/phpMyFAQ), work on your copy and send pull requests. -### 8.6.2 Setup a local phpMyFAQ development environment +### 9.6.2 Setup a local phpMyFAQ development environment Before working on phpMyFAQ, set up a local environment with the following software: - Git -- PHP 8.3 up to 8.6 +- PHP 9.4 up to 9.6 - PHPUnit 12.x - Composer -- Node.js 22+ +- Node.js 24+ - TypeScript 5.x - PNPM - Docker -### 8.6.3 Configure your Git installation +### 9.6.3 Configure your Git installation Set up your user information with your real name and a working e-mail address: @@ -265,7 +308,23 @@ Set up your user information with your real name and a working e-mail address: $ git config --global user.email you@example.com $ git config core.autocrlf # if you're on Windows -### 8.6.4 How to get the phpMyFAQ source code? +### 9.6.4 Background jobs during development + +phpMyFAQ uses the internal queue for asynchronous tasks such as mail delivery. +Run the worker locally while testing features that send email: + +```bash +php bin/worker.php +``` + +Optionally limit processing to a fixed number of jobs: + +```bash +php bin/worker.php 10 +``` + $ git config core.autocrlf # if you're on Windows + +### 9.6.5 How to get the phpMyFAQ source code? Clone your forked phpMyFAQ repository locally: @@ -279,7 +338,7 @@ Add the upstream repository as remote: Please check our [coding standards](https://www.phpmyfaq.de/docs/standards) before sending patches or pull requests. Every PR on GitHub will check the coding standards and tests as well. -### 8.6.5 Run Docker Compose +### 9.6.6 Run Docker Compose The Dockerfile provided in the phpMyFAQ repository only builds an environment to run any release for development purposes. @@ -306,16 +365,16 @@ _Running using named volumes:_ - **elasticsearch**: Open Source Software image (it means it does not have XPack installed) - **opensearch**: OpenSearch image (it means it does not have XPack installed) -_Running apache web server with PHP 8.5 support:_ +_Running apache web server with PHP 9.5 support:_ - **apache**: mounts the `phpmyfaq` folder in place of `/var/www/html`. -_Running nginx web server with PHP 8.5 support:_ +_Running nginx web server with PHP 9.5 support:_ - **nginx**: mounts the `phpmyfaq` folder in place of `/var/www/html`. -- **php-fpm**: PHP-FPM image with PHP 8.5 support +- **php-fpm**: PHP-FPM image with PHP 9.5 support -_Running FrankenPHP with PHP 8.5 support:_ +_Running FrankenPHP with PHP 9.5 support:_ - **frankenphp**: mounts the `phpmyfaq` folder in place of `/var/www/html`. Modern PHP application server built on Caddy with worker mode for better performance. @@ -326,7 +385,7 @@ Then services will be available at the following addresses: - phpMyAdmin: (http://localhost:8000) - pgAdmin: (http://localhost:8008) -### 8.6.6 Fetch third party libraries and install phpMyFAQ +### 9.6.7 Fetch third party libraries and install phpMyFAQ After cloning your forked repository, you have to fetch the 3rd party libraries used in phpMyFAQ: @@ -362,14 +421,14 @@ To run the Vitest-based tests, you can use the following command: $ pnpm test -### 8.6.7 Coding standards +### 9.6.8 Coding standards The following coding standards are used in phpMyFAQ: - PHP: [PER Coding Style 3.0](https://www.php-fig.org/per/coding-style/) - TypeScript with ESLint recommendations -### 8.6.8 Rebase your Patch +### 9.6.9 Rebase your Patch Before submitting your patch, please update your local branch: @@ -379,11 +438,11 @@ Before submitting your patch, please update your local branch: $ git checkout YOUR_BRANCH_NAME $ git rebase main -### 8.6.9 Make a Pull Request +### 9.6.10 Make a Pull Request You can now make a pull request on the phpMyFAQ GitHub repository. -## 8.7 Builtin Twig Extensions +## 9.7 Builtin Twig Extensions phpMyFAQ v4 and later use the Twig template engine for the frontend and the backend. We have added some custom extensions to Twig to make it easier to work with phpMyFAQ. @@ -464,6 +523,14 @@ Example: {{ 'string' | translate }} +### Title slugify Twig Extensions + +The title slugify extension is used to create a slug from a string. + +Example: + + {{ "Hello World"|slugify }} {# outputs: hello-world #} + ### User name Twig Extensions The username extension is used to get the name of a user by its ID. @@ -472,9 +539,9 @@ Example: {{ userId | userName }} -## 8.8 Working with the Docker containers +## 9.8 Working with the Docker containers -### 8.8.1 Create a new SSL certificate +### 9.9.1 Create a new SSL certificate To create a new SSL certificate, you can use the following command: @@ -482,7 +549,7 @@ To create a new SSL certificate, you can use the following command: For more information, please visit the [mkcert](https://github.com/FiloSottile/mkcert) website. -### 8.8.2 Using an OpenLDAP docker container for testing +### 9.9.2 Using an OpenLDAP docker container for testing To test phpMyFAQ during development with an OpenLDAP docker container, you can use the following test setup: diff --git a/docs/index.md b/docs/index.md index 7fc2a75387..a89430c8eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,20 +1,26 @@ # phpMyFAQ Manual phpMyFAQ is a comprehensive, multilingual FAQ system that is entirely database-driven. -It is compatible with a variety of databases for data storage and requires PHP 8.3+ for data access. +It is compatible with a variety of databases for data storage and requires PHP 8.4+ for data access. The system features a multi-language Content Management System equipped with a WYSIWYG editor and an Image Manager. It also provides real-time search capabilities with Elasticsearch or OpenSearch. -phpMyFAQ supports flexible multi-user functionality, -offering user and group-based permissions on categories and records. +phpMyFAQ supports flexible multi-user functionality, offering user and group-based permissions on categories and FAQs. It includes a wiki-like revision feature, a news system, and configurable user-tracking. +Administrators can monitor user activities through detailed log files. +Additionally, phpMyFAQ supports adding its own custom pages to the FAQ system. With support for over 40 languages, it also boasts enhanced automatic content negotiation and HTML5- / CSS3-based responsive templates. -These Twig-based templates allow for the inclusion of your own text and HTML snippets. +These Twig-based templates allow for the inclusion of your own text and HTML snippets. There's also a built-in plugin +system for further customization. -Additional features include PDF support, a backup system, a dynamic sitemap, related FAQs, tagging, a plugin system, +Additional features include PDF support, a backup system, a dynamic sitemap, related FAQs, tagging, a plugin system, and built-in spam protection systems. -It also supports OpenLDAP, Microsoft Active Directory, Microsoft Entra ID, and a MCP Server for AI agents. +phpMyFAQ also supports two-factor authentication (2FA) for enhanced security. +An AI-assisted translation feature helps translate content into multiple languages using Google Cloud Translation, DeepL, +Azure Translator, Amazon Translate, or LibreTranslate. +A REST API is available for integration with other systems. +It also supports OpenLDAP, Microsoft Active Directory, Microsoft Entra ID, and an MCP Server for AI agents. The system is easy to install, thanks to its user-friendly installation script. phpMyFAQ is versatile @@ -24,7 +30,7 @@ This documentation serves as a guide to help you install, update, use, and admin ### Copyright -phpMyFAQ is published under the [Mozilla Public License Version 2.0](http://www.mozilla.org/MPL/2.0/) (MPL). +phpMyFAQ is published under the [Mozilla Public License Version 2.0](https://www.mozilla.org/MPL/2.0/) (MPL). This license guarantees you the free usage of phpMyFAQ, access to the source code, and the right to modify and distribute phpMyFAQ. @@ -40,7 +46,7 @@ protection of the openness and free distribution on the one hand and the interac its licensing model. When compared to other licensing models, its text is short and easily comprehensible, even for newcomers. -This documentation is licensed under a [Creative Commons License](http://creativecommons.org/licenses/by/2.0/). +This documentation is licensed under a [Creative Commons License](https://creativecommons.org/licenses/by/2.0/). ### Support diff --git a/docs/installation.md b/docs/installation.md index 38dba8c9a4..8f52e201ed 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,7 +7,7 @@ To install it, you will need a web server that meets the following requirements: ### PHP requirements -- version 8.3 or later +- version 8.4 or later - memory_limit = 128M (the more the better) - PDO support - cURL support @@ -20,6 +20,11 @@ To install it, you will need a web server that meets the following requirements: - Sodium support - intl support +Suggested PHP extensions: + +- mbstring +- zlib + ### Web server requirements You can use phpMyFAQ with the following web servers: @@ -27,6 +32,7 @@ You can use phpMyFAQ with the following web servers: - [Apache](http://www.apache.org) 2.4 or later (with mod_rewrite) and mod_ssl (if you wish to run phpMyFAQ under SSL) - [Nginx](http://www.nginx.org) 1.0 or later (with URL rewriting) and SSL support - [FrankenPHP](https://frankenphp.dev) 1.0 or later (modern PHP application server built on Caddy) +- [IIS](http://www.iis.net) 7.0 or later (with URL rewriting) and SSL support #### Apache requirements @@ -54,14 +60,17 @@ Please be aware that modules like `mod_security` can cause problems with the ins - [Percona Server](http://www.percona.com) (via MySQLi extension or PDO) - [Azure SQL Database](https://azure.microsoft.com/en-us/products/azure-sql/database) (via PDO, experimental) -The PDO extension is the preferred way to connect to your database server even though it's still marketed as -experimental for now. +The PDO extension is the preferred way to connect to your database server. ### Optional Search engine - [Elasticsearch](https://www.elastic.co/products/elasticsearch) 7.x or 8.x - [OpenSearch](https://opensearch.org/) 2.x +### Optional In-Memory Data Store + +- [Redis](https://redis.io/) 8.x or later + ### Additional requirements - correctly set: access permissions, owner, group @@ -77,7 +86,7 @@ content: ` ``` -## 9.6 Stylesheets +## 10.6 Stylesheets Plugins can provide pre-compiled CSS files that will be automatically injected into both frontend and admin pages. -### 9.6.1 Adding stylesheets to your plugin +### 10.6.1 Adding stylesheets to your plugin Implement the `getStylesheets()` method in your plugin class: @@ -165,7 +165,7 @@ public function getStylesheets(): array - Stylesheets are automatically injected into page `` - Works in both frontend and admin areas -### 9.6.2 Plugin directory structure for CSS +### 10.6.2 Plugin directory structure for CSS ``` /content/plugins/YourPlugin/ @@ -175,11 +175,11 @@ public function getStylesheets(): array └── admin-style.css ``` -## 9.7 Translations +## 10.7 Translations Plugins can provide translations in multiple languages that integrate seamlessly with phpMyFAQ's translation system. -### 9.7.1 Adding translations to your plugin +### 10.7.1 Adding translations to your plugin 1. Implement the `getTranslationsPath()` method: @@ -206,7 +206,7 @@ $PMF_LANG['greeting'] = 'Hallo'; $PMF_LANG['message'] = 'Willkommen zu meinem Plugin!'; ``` -### 9.7.2 Using plugin translations +### 10.7.2 Using plugin translations **In PHP code:** ```php @@ -222,7 +222,7 @@ $message = Translation::get('plugin.YourPlugin.message'); {{ 'plugin.YourPlugin.message' | translate }} ``` -### 9.7.3 Translation key format +### 10.7.3 Translation key format Plugin translations use a namespaced format: ``` @@ -239,7 +239,7 @@ plugin.{PluginName}.{messageKey} - Automatic fallback to English if translation missing in current language - Support all 45+ phpMyFAQ languages -### 9.7.4 Plugin directory structure for translations +### 10.7.4 Plugin directory structure for translations ``` /content/plugins/YourPlugin/ @@ -251,11 +251,11 @@ plugin.{PluginName}.{messageKey} └── language_es.php ``` -## 9.8 JavaScript +## 10.8 JavaScript Plugins can provide pre-compiled JavaScript files that will be automatically injected into both frontend and admin pages. -### 9.8.1 Adding JavaScript to your plugin +### 10.8.1 Adding JavaScript to your plugin Implement the `getScripts()` method in your plugin class: @@ -276,7 +276,7 @@ public function getScripts(): array - Scripts are automatically injected at the end of `` - Works in both frontend and admin areas -### 9.8.2 Plugin directory structure for JavaScript +### 10.8.2 Plugin directory structure for JavaScript ``` /content/plugins/YourPlugin/ @@ -286,7 +286,7 @@ public function getScripts(): array └── admin-script.js ``` -### 9.8.3 JavaScript best practices +### 10.8.3 JavaScript best practices **Example frontend script:** ```javascript @@ -314,7 +314,7 @@ public function getScripts(): array - Validate and sanitize user input - Use DOM API safely -## 9.9 Complete plugin example with CSS, JavaScript, and translations +## 10.9 Complete plugin example with CSS, JavaScript, and translations See the `EnhancedExample` plugin for a complete working example demonstrating all features: @@ -341,7 +341,7 @@ See the `EnhancedExample` plugin for a complete working example demonstrating al

{{ 'plugin.EnhancedExample.adminMessage' | translate }}

``` -## 9.10 Plugin version history +## 10.10 Plugin version history - 0.1.0: Initial version, shipped with phpMyFAQ 4.0.0 - 0.2.0: Added support for plugin configuration, stylesheets, JavaScript, and translations, shipped with phpMyFAQ 4.1.0 diff --git a/docs/thank-you.md b/docs/thank-you.md index d36b7cd088..0902fd03f6 100644 --- a/docs/thank-you.md +++ b/docs/thank-you.md @@ -1,4 +1,4 @@ -# 11. Thank you! +# 12. Thank you! Thank you for using phpMyFAQ! :-) diff --git a/docs/update.md b/docs/update.md index 73d686f1c5..78d7b0624d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,29 +1,29 @@ # 5. Updating phpMyFAQ -First, please download the latest package of phpMyFAQ. Upgrading to phpMyFAQ 4.1 is possible from the following +First, please download the latest package of phpMyFAQ. Upgrading to phpMyFAQ 4.2 is possible from the following versions: -- phpMyFAQ 3.0.x - phpMyFAQ 3.1.x - phpMyFAQ 3.2.x - phpMyFAQ 4.0.x - phpMyFAQ 4.1.x If you're running an older version of phpMyFAQ than listed above, we recommend a new and fresh installation. If you need -support for updating an old FAQ from the 1.x or 2.x series, [please email us](mailto:thorsten_AT_phpmyfaq_DOT_de). +support for updating an old FAQ from the 1.x, 2.x, or 3.0 series, [please email us](mailto:thorsten_AT_phpmyfaq_DOT_de). Please note that the requirements of phpMyFAQ have to be fulfilled. ## Before you upgrade -Please make sure that you're running at least PHP 8.3, otherwise the upgrade won't work. +Please make sure that you're running at least PHP 8.4, otherwise the upgrade won't work. -## Upgrading from phpMyFAQ 3.0.x +## Upgrading from phpMyFAQ 3.1.x -Upgrading from 3.0.x is a major upgrade. -Your existing templates will not work with phpMyFAQ 4.1. +Upgrading from 3.1.x is a major upgrade. +Your existing templates will not work with phpMyFAQ 4.2. Please make a full backup before you run the upgrade! First, log in as admin into the admin section and enable the maintenance mode. +(Configuration >> Edit Configuration >> Set FAQ in maintenance mode) Second, you have to delete all files **except**: - in the directory **config/** @@ -40,10 +40,10 @@ URL in your browser: Click the button of the update script, your version will automatically be updated. -## Upgrading from phpMyFAQ 3.1.x +## Upgrading from phpMyFAQ 3.2.x -Upgrading from 3.1.x is a major upgrade. -Your existing templates will not work with phpMyFAQ 4.1. +Upgrading from 3.2.x is a major upgrade. +Your existing templates will not work with phpMyFAQ 4.2. Please make a full backup before you run the upgrade! First, log in as admin into the admin section and enable the maintenance mode. (Configuration >> Edit Configuration >> Set FAQ in maintenance mode) @@ -52,6 +52,7 @@ Second, you have to delete all files **except**: - in the directory **config/** - keep the file **database.php** - only if using LDAP/ActiveDirectory support also keep the file **ldap.php** + - only if using EntraID support also keep the file **azure.php** - the directory **attachments/** - the directory **data/** - the directory **images/** @@ -63,31 +64,25 @@ URL in your browser: Click the button of the update script, your version will automatically be updated. -## Upgrading from phpMyFAQ 3.2.x +## Upgrading from phpMyFAQ 4.0.x + +### Manual upgrade -Upgrading from 3.2.x is a major upgrade. -Your existing templates will not work with phpMyFAQ 4.1. Please make a full backup before you run the upgrade! First, log in as admin into the admin section and enable the maintenance mode. (Configuration >> Edit Configuration >> Set FAQ in maintenance mode) Second, you have to delete all files **except**: -- in the directory **config/** - - keep the file **database.php** - - only if using LDAP/ActiveDirectory support also keep the file **ldap.php** - - only if using EntraID support also keep the file **azure.php** -- the directory **attachments/** -- the directory **data/** -- the directory **images/** +- all files in the directory **content/** -Download the latest phpMyFAQ package and copy the contents into your existing FAQ directory, then open the following +Download the latest phpMyFAQ package and copy the contents into your existing FAQ directory, the open the following URL in your browser: `http://www.example.com/faq/update` Click the button of the update script, your version will automatically be updated. -## Upgrading from phpMyFAQ 4.0.x +## Upgrading from phpMyFAQ 4.1.x ### Manual upgrade @@ -105,7 +100,24 @@ URL in your browser: Click the button of the update script, your version will automatically be updated. -## Upgrading from phpMyFAQ 4.1.x +### Online update (Experimental feature) + +If you're running phpMyFAQ 4.0.0 or later, you can use the built-in online update feature. +Log in as admin into the admin section and enable the maintenance mode. +(Configuration >> Edit Configuration >> Set FAQ in maintenance mode) +Then go to the "phpMyFAQ Update" page in the configuration section and click through the update wizard: + +1. Check for System Health: this checks if your system is ready for the upgrade +2. Check for Updates: this checks if there is a new version of phpMyFAQ available +3. Download of phpMyFAQ: this downloads the latest version of phpMyFAQ in the background, this can take some seconds +4. Extracting phpMyFAQ: this extracts the downloaded archive, this can take a while +5. Install downloaded package: first, it creates a backup of your current installation, then it copies the downloaded + files into your installation, and in the end, the database is updated + +Note: +The online update feature is experimental and might not work in all environments. + +## Upgrading from phpMyFAQ 4.2.x ### Manual upgrade @@ -140,7 +152,7 @@ Then go to the "phpMyFAQ Update" page in the configuration section and click thr Note: The online update feature is experimental and might not work in all environments. -## Modifying templates for phpMyFAQ 4.1 +## Modifying templates for phpMyFAQ 4.2 We recommend you take a look at the main [Bootstrap documentation](https://getbootstrap.com/). Please remember that the style sheets are written with [SCSS](https://sass-lang.com/). diff --git a/eslint.config.mjs b/eslint.config.mjs index e56bdf2663..3c93b3d2cd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,5 +18,23 @@ const ignoresConfig = globalIgnores([ export default defineConfig([ { extends: [ignoresConfig, eslint.configs.recommended, tseslint.configs.strict], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['phpmyfaq/sw.js'], + languageOptions: { + globals: { + self: 'readonly', + }, + }, }, ]); diff --git a/mago.toml b/mago.toml index f67aa24679..6373d17267 100644 --- a/mago.toml +++ b/mago.toml @@ -1,6 +1,6 @@ # Mago configuration file # For more information, see https://mago.carthage.software/#/getting-started/configuration -php-version = "8.3.0" +php-version = "8.4.0" [source] paths = ["./phpmyfaq/src/phpMyFAQ"] @@ -18,9 +18,10 @@ null-type-hint = "question" integrations = ["symfony", "php-unit"] [linter.rules] -cyclomatic-complexity = { threshold = 50 } -halstead = { volume-threshold = 3000, effort-threshold = 20000 } -kan-defect = { threshold = 3.0 } +cyclomatic-complexity = { threshold = 75 } +halstead = { difficulty-threshold = 15, volume-threshold = 3000, effort-threshold = 25000 } +kan-defect = { threshold = 4.0 } +no-boolean-flag-parameter = { enabled = false } too-many-enum-cases = { threshold = 50 } too-many-methods = { threshold = 20 } too-many-properties = { threshold = 15 } diff --git a/mkdocs.yml b/mkdocs.yml index 32732b29f5..67aff2a831 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: phpMyFAQ v4.1 +site_name: phpMyFAQ v4.2 theme: name: readthedocs highlightjs: true @@ -12,7 +12,8 @@ nav: - 5. Updating phpMyFAQ: 'update.md' - 6. Use phpMyFAQ: 'usage.md' - 7. Manage phpMyFAQ: 'administration.md' - - 8. Developer documentation: 'development.md' - - 9. Plugins: 'plugins.md' - - 10. MCP Server: 'mcp-server.md' - - 11. Thank you!: 'thank-you.md' + - 8. AI-Assisted Translation Feature: 'ai-translation.md' + - 9. Developer documentation: 'development.md' + - 10. Plugins: 'plugins.md' + - 11. MCP Server: 'mcp-server.md' + - 12. Thank you!: 'thank-you.md' diff --git a/nginx.conf b/nginx.conf index 5a7425b4cb..6993a4af48 100644 --- a/nginx.conf +++ b/nginx.conf @@ -25,14 +25,6 @@ server { # Increase max upload size (adjust as needed) client_max_body_size 100M; - # Global rewrite for Pretty URL: Forgot password - rewrite ^/forgot-password/?$ /index.php?action=password last; - - # Pretty URL: Forgot password (exact match) - location = /forgot-password { - rewrite ^ /index.php?action=password last; - } - rewrite // / break; rewrite ^/$ /index.php last; @@ -81,15 +73,19 @@ server { add_header X-Frame-Options SAMEORIGIN; # Error pages - error_page 403 = @error403; error_page 404 = @error404; - location @error403 { - rewrite ^ /index.php?action=403 last; + location @error404 { + rewrite ^ /404.html last; } - location @error404 { - rewrite ^ /index.php?action=404 last; + # Service Worker - no caching, proper headers + location = /sw.js { + add_header Content-Type "application/javascript; charset=utf-8"; + add_header Service-Worker-Allowed "/"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + try_files $uri =404; } location / { @@ -98,79 +94,26 @@ server { } location @rewriteapp { - # General pages - rewrite ^/add-faq\.html$ /index.php?action=add last; - rewrite ^/add-question\.html$ /index.php?action=ask last; - rewrite ^/show-categories\.html$ /index.php?action=show last; - rewrite ^/forgot-password/?$ /index.php?action=password last; - rewrite ^/(search|open-questions|help|contact|glossary|overview|login|privacy|index)\.html$ /index.php?action=$1 last; - rewrite ^/(login)$ /index.php?action=login last; - - # a solution id page - rewrite ^/solution_id_([0-9]+)\.html$ /index.php?solution_id=$1 last; - - # the bookmarks page - rewrite ^/bookmarks\.html$ /index.php?action=bookmarks last; - - # PMF faq record page - rewrite ^/content/([0-9]+)/([0-9]+)/([a-z\-_]+)/(.+)\.htm(l?)$ /index.php?action=faq&cat=$1&id=$2&artlang=$3 last; - - # PMF category page with page count - rewrite ^/category/([0-9]+)/([0-9]+)/(.+)\.html$ /index.php?action=show&cat=$1&seite=$2 last; - - # PMF category page - rewrite ^/category/([0-9]+)/(.+)\.html$ /index.php?action=show&cat=$1 last; - - # PMF news page - rewrite ^/news/([0-9]+)/([a-z\-_]+)/(.+)\.html$ /index.php?action=news&newsid=$1&newslang=$2 last; - - # PMF sitemap - rewrite ^/sitemap/([^\/]+)/([a-z\-_]+)\.htm(l?)$ /index.php?action=sitemap&letter=$1&lang=$2 last; - - # PMF Google sitemap - rewrite ^/sitemap\.xml$ /sitemap.xml.php last; - rewrite ^/sitemap\.gz$ /sitemap.xml.php?gz=1 last; - rewrite ^/sitemap\.xml\.gz$ /sitemap.xml.php?gz=1 last; - - # robots.txt - rewrite ^/robots\.txt$ /robots.txt.php last; - - # llms.txt - rewrite ^/llms\.txt$ /llms.txt.php last; - - # PMF tags page with page count - rewrite ^/tags/([0-9]+)/([0-9]+)/(.+)\.htm(l?)$ /index.php?action=search&tagging_id=$1&seite=$2 last; - - # PMF tags page - rewrite ^/tags/([0-9]+)/([^\/]+)\.htm(l?)$ /index.php?action=search&tagging_id=$1 last; - - # Authentication services - rewrite ^/services/webauthn/(.*)$ /services/webauthn/index.php last; - - # User pages - rewrite ^/user/(ucp|bookmarks|request-removal|logout|register)$ /index.php?action=$1 last; # Administration API - rewrite ^/admin/api/(.*)$ /admin/api/index.php last; - - # Administration pages - rewrite ^/admin/(.*)$ /admin/index.php last; + rewrite ^/admin/api/ /admin/api/index.php last; - # REST API v3.0 and v3.1 - rewrite ^/api/v3\.[01]/(.*)$ /api/index.php last; + # Administration pages (redirect /admin to /admin/) + rewrite ^/admin$ /admin/ permanent; + rewrite ^/admin/ /admin/index.php last; - # Private APIs - rewrite ^/api/(.*)$ /api/index.php last; + # API routes (all API endpoints) + rewrite ^/api/ /api/index.php last; - # Setup APIs - rewrite ^/api/setup/(check|backup|update-database)$ /api/index.php last; + # Setup pages + rewrite ^/setup/ /setup/index.php last; - # Setup and update pages - rewrite ^/setup/?$ /setup/index.php last; - rewrite ^/update/?$ /update/index.php last; + # Update page - route directly to index.php (handled by Symfony router) + rewrite ^/update$ /index.php last; + rewrite ^/update/ /index.php last; - # Fallback: return 404 if no rewrite rule matched - return 404; + # Front controller: route all other requests to index.php (Symfony Router) + rewrite ^ /index.php last; } location /admin/assets { diff --git a/package.json b/package.json index 29bf056f11..476431ef0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thorsten/phpmyfaq", - "version": "4.1.0-RC.5", + "version": "4.2.0-alpha", "description": "phpMyFAQ", "repository": "git://github.com/thorsten/phpMyFAQ.git", "author": "Thorsten Rinne", @@ -57,14 +57,14 @@ "@types/highlightjs": "^9.12.6", "@types/jsdom": "^27.0.0", "@types/masonry-layout": "^4.2.8", - "@types/node": "^24.10.13", + "@types/node": "^25.2.3", "@types/sortablejs": "^1.15.9", - "@vitest/coverage-istanbul": "^4.0.18", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-istanbul": "^4.0.17", + "@vitest/coverage-v8": "^4.0.17", "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "husky": "^9.1.7", - "jsdom": "^27.4.0", + "jsdom": "^28.0.0", "postcss": "^8.5.6", "prettier": "^3.8.1", "pretty-quick": "^4.2.2", @@ -77,7 +77,7 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-minify": "^2.1.0", "vite-plugin-static-copy": "^3.2.0", - "vitest": "^4.0.18", + "vitest": "^4.0.17", "vitest-fetch-mock": "^0.4.5" }, "husky": { diff --git a/phpmyfaq/.env.example b/phpmyfaq/.env.example index 07f22bbbf3..6c7f5a168f 100644 --- a/phpmyfaq/.env.example +++ b/phpmyfaq/.env.example @@ -1,9 +1,32 @@ -# Debug Configuration, valid values are true or false +# phpMyFAQ Environment Configuration + +# Application Environment +# Options: production, development, staging +APP_ENV=production + +# Debug Mode +# Enable detailed error messages and debug tools +# NOTE: Cache is automatically disabled when DEBUG=true (for development) DEBUG=false + +# Debug Query Logging +# Log all database queries (performance impact) DEBUG_LOG_QUERIES=false -# Application Configuration, valid values are production, development, or testing -APP_ENV=development +# Error Log +# Where to write error logs +ERROR_LOG=php://stderr + +# Routing Configuration +# Enable route caching for better performance in production +# Route caching uses reflection to discover routes, then caches the result +# Disable in development to see route changes immediately +ROUTING_CACHE_ENABLED=true + +# Route Cache Directory (optional) +# Default: {PMF_ROOT_DIR}/cache/routes +# Only override if you need a custom location (must be an absolute path) +# ROUTING_CACHE_DIR=/custom/path/to/cache # ElasticSearch and OpenSearch Configuration OPENSEARCH_BASE_URI=http://127.0.0.1:9201 diff --git a/phpmyfaq/.htaccess b/phpmyfaq/.htaccess index b82424bd49..574e29686a 100644 --- a/phpmyfaq/.htaccess +++ b/phpmyfaq/.htaccess @@ -106,62 +106,24 @@ Header set Access-Control-Allow-Headers "Content-Type, Authorization" # Block zip files in content directory RewriteRule ^content/.*\.zip$ - [F,L] # Exclude assets from being handled by Symfony Router - RewriteRule ^(admin/assets)($|/) - [L] + RewriteRule ^admin/assets($|/) - [L] # Error pages - ErrorDocument 404 /index.php?action=404 - # General pages - RewriteRule ^add-faq.html$ index.php?action=add [L,QSA] - RewriteRule ^add-question.html$ index.php?action=ask [L,QSA] - RewriteRule ^show-categories.html$ index.php?action=show [L,QSA] - RewriteRule ^forgot-password$ index.php?action=password [L,QSA] - RewriteRule ^(search|open-questions|contact|glossary|overview|login|privacy)\.html$ index.php?action=$1 [L,QSA] - RewriteRule ^(login) index.php?action=login [L,QSA] - # start page - RewriteRule ^index.html$ index.php [L,QSA] - # a solution ID page - RewriteCond %{REQUEST_URI} solution_id_([0-9]+)\.html$ [NC] - RewriteRule ^solution_id_(.*)\.html$ index.php?solution_id=$1 [L,QSA] - # the bookmarks page - RewriteRule ^bookmarks\.html$ index.php?action=bookmarks [L,QSA] - # phpMyFAQ faq record page - RewriteRule ^content/([0-9]+)/([0-9]+)/([a-z\-_]+)/(.+)\.htm(l?)$ index.php?action=faq&cat=$1&id=$2&artlang=$3 [L,QSA] - # phpMyFAQ category page with page count - RewriteRule ^category/([0-9]+)/([0-9]+)/(.+)\.htm(l?)$ index.php?action=show&cat=$1&seite=$2 [L,QSA] - # phpMyFAQ category page - RewriteRule ^category/([0-9]+)/(.+)\.htm(l?)$ index.php?action=show&cat=$1 [L,QSA] - # phpMyFAQ news page - RewriteRule ^news/([0-9]+)/([a-z\-_]+)/(.+)\.htm(l?)$ index.php?action=news&newsid=$1&newslang=$2 [L,QSA] - # phpMyFAQ sitemap - RewriteRule ^sitemap/([^/]+)/([a-z\-_]+)\.htm(l?)$ index.php?action=sitemap&letter=$1&lang=$2 [L,QSA] - # phpMyFAQ Google sitemap - RewriteRule ^sitemap.xml$ sitemap.xml.php [L,QSA] - RewriteRule ^sitemap.gz$ sitemap.xml.php?gz=1 [L,QSA] - RewriteRule ^sitemap.xml.gz$ sitemap.xml.php?gz=1 [L,QSA] - # robots.txt - RewriteRule ^robots.txt$ robots.txt.php [L,QSA] - # llms.txt - RewriteRule ^llms.txt$ llms.txt.php [L,QSA] - # phpMyFAQ tags page with page count - RewriteRule ^tags/([0-9]+)/([0-9]+)/(.+)\.htm(l?)$ index.php?action=search&tagging_id=$1&seite=$2 [L,QSA] - # phpMyFAQ tags page - RewriteRule ^tags/([0-9]+)/([^/]+)\.htm(l?)$ index.php?action=search&tagging_id=$1 [L,QSA] - # Authentication services - RewriteRule ^services/webauthn(.*) services/webauthn/index.php [L,QSA] - # User pages - RewriteRule ^user/(ucp|bookmarks|request-removal|logout|register) index.php?action=$1 [L,QSA] - # Setup and update pages - RewriteCond %{REQUEST_URI} ^(.*/)setup/ - RewriteRule ^(.*/)?setup/(.*) $1setup/index.php [L,QSA] - RewriteRule ^update/(.*) update/index.php [L,QSA] + ErrorDocument 404 /404.html # Administration API - RewriteRule ^admin/api/(.*) admin/api/index.php [L,QSA] - # Administration pages - RewriteRule ^admin/(.*) admin/index.php [L,QSA] - # Private APIs - RewriteRule ^api/(autocomplete|bookmark/delete|bookmark/create|user/data/update|user/password/update|user/request-removal|user/remove-twofactor|contact|voting|register|captcha|share|comment/create|faq/create|question/create|webauthn/prepare|webauthn/register|webauthn/prepare-login|webauthn/login|translations) api/index.php [L,QSA] - # Setup APIs - RewriteRule ^api/setup/(check|backup|update-database) api/index.php [L,QSA] - # REST API v3.0 and v3.1 - # * http://[...]/api/v3.x/ - RewriteRule ^api/v3\.[01]/(.*) api/index.php [L,QSA] + RewriteRule ^admin/api/ admin/api/index.php [L,QSA] + # Administration pages (redirect /admin to /admin/) + RewriteRule ^admin$ admin/ [R=301,L] + RewriteRule ^admin/ admin/index.php [L,QSA] + # API routes (all API endpoints) + RewriteRule ^api/ api/index.php [L,QSA] + # Setup pages + RewriteRule ^setup/ setup/index.php [L,QSA] + # Update page - route directly to index.php (handled by Symfony router) + RewriteRule ^update$ index.php [L,QSA] + RewriteRule ^update/ index.php [L,QSA] + # Front controller: route all other requests to index.php (Symfony Router) + # Skip if the file or directory exists + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] diff --git a/phpmyfaq/404.php b/phpmyfaq/404.php deleted file mode 100644 index a036a5185f..0000000000 --- a/phpmyfaq/404.php +++ /dev/null @@ -1,34 +0,0 @@ - - * @copyright 2019-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2019-01-25 - */ - -use phpMyFAQ\Enums\SessionActionType; -use phpMyFAQ\Twig\TwigWrapper; - -if (!defined('IS_VALID_PHPMYFAQ')) { - http_response_code(400); - exit(); -} - -$faqConfig = $container->get('phpmyfaq.configuration'); -$user = $container->get('phpmyfaq.user.current_user'); - -$faqSession = $container->get('phpmyfaq.user.session'); -$faqSession->setCurrentUser($user); -$faqSession->userTracking(SessionActionType::NOT_FOUND->value, 0); - -$twig = new TwigWrapper(PMF_ROOT_DIR . '/assets/templates/'); -$twigTemplate = $twig->loadTemplate('./404.twig'); diff --git a/phpmyfaq/add.php b/phpmyfaq/add.php deleted file mode 100644 index ef456f8956..0000000000 --- a/phpmyfaq/add.php +++ /dev/null @@ -1,147 +0,0 @@ - - * @copyright 2002-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2002-09-16 - */ - -use phpMyFAQ\Category; -use phpMyFAQ\Enums\Forms\FormIds; -use phpMyFAQ\Enums\PermissionType; -use phpMyFAQ\Filter; -use phpMyFAQ\Forms; -use phpMyFAQ\Question; -use phpMyFAQ\Strings; -use phpMyFAQ\System; -use phpMyFAQ\Translation; -use phpMyFAQ\Twig\TwigWrapper; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Twig\TwigFilter; - -if (!defined('IS_VALID_PHPMYFAQ')) { - http_response_code(400); - exit(); -} - -$request = Request::createFromGlobals(); -$faqSystem = new System(); - -$faqConfig = $container->get('phpmyfaq.configuration'); -$user = $container->get('phpmyfaq.user.current_user'); - -$faqSession = $container->get('phpmyfaq.user.session'); -$faqSession->setCurrentUser($user); - -// Check user permissions -if (-1 === $user->getUserId() && !$faqConfig->get('records.allowNewFaqsForGuests')) { - $response = new RedirectResponse($faqSystem->getSystemUri($faqConfig) . 'login'); - $response->send(); -} - -// Check permission to add new faqs -if (-1 !== $user->getUserId() && !$user->perm->hasPermission($user->getUserId(), PermissionType::FAQ_ADD->value)) { - $response = new RedirectResponse($faqSystem->getSystemUri($faqConfig)); - $response->send(); -} - -$captcha = $container->get('phpmyfaq.captcha'); - -$questionObject = new Question($faqConfig); - -$faqSession->userTracking('new_entry', 0); - -// Get possible user input -$selectedQuestion = Filter::filterVar($request->query->get('question'), FILTER_VALIDATE_INT); -$selectedCategory = Filter::filterVar($request->query->get('cat'), FILTER_VALIDATE_INT, -1); -$question = ''; -$readonly = ''; -$displayFullForm = false; -if (!is_null($selectedQuestion)) { - $questionData = $questionObject->get($selectedQuestion); - $question = $questionData['question']; - if (Strings::strlen($question) !== 0) { - $readonly = ' readonly'; - } - - // Display full form even if the user switched off single fields because of use together with answering open - // questions - $displayFullForm = true; -} - -$category = new Category($faqConfig, $currentGroups); -$category->transform(0); -$category->buildCategoryTree(); - -$captchaHelper = $container->get('phpmyfaq.captcha.helper.captcha_helper'); - -$forms = new Forms($faqConfig); -$formData = $forms->getFormData(FormIds::ADD_NEW_FAQ->value); - -$categories = $category->getAllCategoryIds(); - -$twig = new TwigWrapper(PMF_ROOT_DIR . '/assets/templates/'); -$twig->addFilter(new TwigFilter('repeat', fn($string, $times): string => str_repeat((string) $string, $times))); -$twigTemplate = $twig->loadTemplate('./add.twig'); - -// Twig template variables -$templateVars = [ - ...$templateVars, - 'title' => sprintf('%s - %s', Translation::get(key: 'msgAddContent'), $faqConfig->getTitle()), - 'metaDescription' => sprintf('%s | %s', Translation::get(key: 'msgNewContentHeader'), $faqConfig->getTitle()), - 'msgNewContentHeader' => Translation::get(key: 'msgNewContentHeader'), - 'msgNewContentAddon' => Translation::get(key: 'msgNewContentAddon'), - 'lang' => $Language->getLanguage(), - 'openQuestionID' => $selectedQuestion, - 'defaultContentMail' => $user->getUserId() > 0 ? $user->getUserData('email') : '', - 'defaultContentName' => $user->getUserId() > 0 ? $user->getUserData('display_name') : '', - 'msgNewContentName' => Translation::get(key: 'msgNewContentName'), - 'msgNewContentMail' => Translation::get(key: 'msgNewContentMail'), - 'msgNewContentCategory' => Translation::get(key: 'msgNewContentCategory'), - 'selectedCategory' => $selectedCategory, - 'categories' => $category->getCategoryTree(), - 'msgNewContentTheme' => Translation::get(key: 'msgNewContentTheme'), - 'readonly' => $readonly, - 'question' => $question, - 'msgNewContentArticle' => Translation::get(key: 'msgNewContentArticle'), - 'msgNewContentKeywords' => Translation::get(key: 'msgNewContentKeywords'), - 'msgNewContentLink' => Translation::get(key: 'msgNewContentLink'), - 'captchaFieldset' => $captchaHelper->renderCaptcha( - $captcha, - 'add', - Translation::get(key: 'msgCaptcha'), - $user->isLoggedIn(), - ), - 'msgNewContentSubmit' => Translation::get(key: 'msgNewContentSubmit'), - 'enableWysiwygEditor' => $faqConfig->get('main.enableWysiwygEditorFrontend'), - 'currentTimestamp' => $request->server->get('REQUEST_TIME'), - 'msgSeparateKeywordsWithCommas' => Translation::get(key: 'msgSeparateKeywordsWithCommas'), - 'noCategories' => $categories === [], - 'msgFormDisabledDueToMissingCategories' => Translation::get(key: 'msgFormDisabledDueToMissingCategories'), - 'displayFullForm' => $displayFullForm, -]; - -// Collect data for displaying form -foreach ($formData as $input) { - $active = sprintf('id%d_active', (int) $input->input_id); - $label = sprintf('id%d_label', (int) $input->input_id); - $required = sprintf('id%d_required', (int) $input->input_id); - $templateVars = [ - ...$templateVars, - $active => (bool) $input->input_active, - $label => $input->input_label, - $required => (int) $input->input_required !== 0 ? 'required' : '', - ]; -} - -return $templateVars; diff --git a/phpmyfaq/admin/api/index.php b/phpmyfaq/admin/api/index.php index 9e8d84a5a2..ae25f6fcaf 100644 --- a/phpmyfaq/admin/api/index.php +++ b/phpmyfaq/admin/api/index.php @@ -17,11 +17,37 @@ */ use phpMyFAQ\Application; +use phpMyFAQ\Core\Exception\DatabaseConnectionException; +use phpMyFAQ\Environment; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; -require '../../src/Bootstrap.php'; +try { + require '../../src/Bootstrap.php'; +} catch (DatabaseConnectionException $exception) { + $errorMessage = Environment::isDebugMode() + ? $exception->getMessage() + : 'The database server is currently unavailable. Please try again later.'; + + $problemDetails = [ + 'type' => '/problems/database-unavailable', + 'title' => 'Database Connection Error', + 'status' => Response::HTTP_INTERNAL_SERVER_ERROR, + 'detail' => $errorMessage, + 'instance' => $_SERVER['REQUEST_URI'] ?? '/api', + ]; + + $response = new JsonResponse( + data: $problemDetails, + status: Response::HTTP_INTERNAL_SERVER_ERROR, + headers: ['Content-Type' => 'application/problem+json'] + ); + $response->send(); + exit(1); +} // // Service Containers @@ -34,11 +60,13 @@ echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); } -$routes = include PMF_SRC_DIR . '/admin-api-routes.php'; - $app = new Application($container); +$app->setAdminContext(true); +$app->setApiContext(true); +$app->routingContext = 'admin-api'; try { - $app->run($routes); + // Autoload routes from attributes (falls back to api-routes.php during migration) + $app->run(); } catch (Exception $exception) { echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); } diff --git a/phpmyfaq/admin/assets/scss/layout/_configuration.scss b/phpmyfaq/admin/assets/scss/layout/_configuration.scss index 45d731b0a6..d33092a7d6 100644 --- a/phpmyfaq/admin/assets/scss/layout/_configuration.scss +++ b/phpmyfaq/admin/assets/scss/layout/_configuration.scss @@ -3,9 +3,81 @@ // #pmf-admin-configuration-container { + .pmf-configuration-filter { + background: $body-bg; + position: sticky; + top: 0; + z-index: 2; + padding-top: 0.125rem; + } + + .pmf-configuration-tabs { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; + } + + .pmf-configuration-group { + color: $secondary; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + list-style: none; + margin: 0.75rem 0 0.25rem; + text-transform: uppercase; + } + + .pmf-configuration-group span { + display: block; + padding: 0 0.75rem; + } + + .pmf-configuration-tabs .pmf-configuration-group:first-child { + margin-top: 0.25rem; + } + + .pmf-configuration-tabs .nav-item { + flex: 0 0 auto; + } + + .pmf-configuration-tabs .nav-link { + white-space: nowrap; + border-radius: 0; + } + + @media (min-width: 992px) { + .pmf-configuration-filter { + margin-bottom: 0.5rem; + } + + .pmf-configuration-tabs { + overflow: visible; + max-height: none; + padding-right: 0; + scrollbar-width: auto; + } + + .pmf-configuration-tabs .nav-item { + width: 100%; + } + + .pmf-configuration-tabs .nav-link { + width: 100%; + text-align: left; + } + + .pmf-configuration-group span { + padding-left: 0.5rem; + } + } + a.nav-link.active { background: $light-bg-subtle; + border-radius: var(--bs-border-radius-sm); + border: 1px solid var(--bs-gray-300); } + .tab-content { color: $black; padding: 5px 15px; diff --git a/phpmyfaq/admin/assets/scss/layout/_sticky.scss b/phpmyfaq/admin/assets/scss/layout/_sticky.scss new file mode 100644 index 0000000000..650bf73092 --- /dev/null +++ b/phpmyfaq/admin/assets/scss/layout/_sticky.scss @@ -0,0 +1,36 @@ +// +// Sticky FAQ List +// + +.list-group-numbered > .list-group-item::before { + margin-left: 1.25rem; +} + +.no-drag { + cursor: default !important; + background-color: rgba(var(--bs-light-rgb), 0.1); +} + +.js-unstick-button.sticky-toggle-btn { + color: var(--bs-secondary) !important; + transition: all 0.15s ease-in-out; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + text-decoration: none !important; +} + +.js-unstick-button.sticky-toggle-btn i.sticky-icon { + font-size: 1.25rem; + font-style: normal; +} + +.js-unstick-button.sticky-toggle-btn:hover { + color: var(--bs-danger) !important; +} + +.js-unstick-button.sticky-toggle-btn:hover i.sticky-icon::before { + content: '\f4ea' !important; +} diff --git a/phpmyfaq/admin/assets/scss/style.scss b/phpmyfaq/admin/assets/scss/style.scss index 40516336c5..73448db6c5 100644 --- a/phpmyfaq/admin/assets/scss/style.scss +++ b/phpmyfaq/admin/assets/scss/style.scss @@ -46,6 +46,7 @@ @import 'layout/editor'; @import 'layout/login'; @import 'layout/users'; +@import 'layout/sticky'; // Navigation @import 'navigation/nav.scss'; diff --git a/phpmyfaq/admin/assets/src/api/attachment.ts b/phpmyfaq/admin/assets/src/api/attachment.ts index fa73eeaba1..2d800b6cf2 100644 --- a/phpmyfaq/admin/assets/src/api/attachment.ts +++ b/phpmyfaq/admin/assets/src/api/attachment.ts @@ -13,10 +13,10 @@ * @since 2024-05-01 */ -import { Response } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; -export const deleteAttachments = async (attachmentId: string, csrfToken: string): Promise => { - const response = await fetch('./api/content/attachments', { +export const deleteAttachments = async (attachmentId: string, csrfToken: string): Promise => { + return await fetchJson('./api/content/attachments', { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', @@ -24,12 +24,10 @@ export const deleteAttachments = async (attachmentId: string, csrfToken: string) }, body: JSON.stringify({ attId: attachmentId, csrf: csrfToken }), }); - - return await response.json(); }; -export const refreshAttachments = async (attachmentId: string, csrfToken: string): Promise => { - const response = await fetch('./api/content/attachments/refresh', { +export const refreshAttachments = async (attachmentId: string, csrfToken: string): Promise => { + return await fetchJson('./api/content/attachments/refresh', { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -37,16 +35,12 @@ export const refreshAttachments = async (attachmentId: string, csrfToken: string }, body: JSON.stringify({ attId: attachmentId, csrf: csrfToken }), }); - - return await response.json(); }; -export const uploadAttachments = async (formData: FormData): Promise => { - const response = await fetch('./api/content/attachments/upload', { +export const uploadAttachments = async (formData: FormData): Promise => { + return await fetchJson('./api/content/attachments/upload', { method: 'POST', cache: 'no-cache', body: formData, }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/category.ts b/phpmyfaq/admin/assets/src/api/category.ts index fb33cbc0c9..6f9a047cba 100644 --- a/phpmyfaq/admin/assets/src/api/category.ts +++ b/phpmyfaq/admin/assets/src/api/category.ts @@ -12,10 +12,10 @@ * @link https://www.phpmyfaq.de * @since 2023-12-28 */ -import { CategoryTranslations, Response } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; -export const fetchCategoryTranslations = async (categoryId: string): Promise => { - const response = await fetch(`./api/category/translations/${categoryId}`, { +export const fetchCategoryTranslations = async (categoryId: string): Promise => { + return await fetchJson(`./api/category/translations/${categoryId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -24,16 +24,10 @@ export const fetchCategoryTranslations = async (categoryId: string): Promise => { - const response = await fetch(`./api/category/delete`, { +export const deleteCategory = async (categoryId: string, language: string, csrfToken: string): Promise => { + return await fetchJson(`./api/category/delete`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -47,16 +41,14 @@ export const deleteCategory = async ( redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; export const setCategoryTree = async ( categoryTree: unknown, categoryId: string, csrfToken: string -): Promise => { - const response = await fetch('./api/category/update-order', { +): Promise => { + return await fetchJson('./api/category/update-order', { method: 'POST', cache: 'no-cache', headers: { @@ -70,6 +62,4 @@ export const setCategoryTree = async ( redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/configuration.test.ts b/phpmyfaq/admin/assets/src/api/configuration.test.ts index 7fc6a2f5a0..30e99e9620 100644 --- a/phpmyfaq/admin/assets/src/api/configuration.test.ts +++ b/phpmyfaq/admin/assets/src/api/configuration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchConfiguration, fetchFaqsSortingKeys, @@ -7,27 +7,37 @@ import { fetchReleaseEnvironment, fetchSearchRelevance, fetchSeoMetaTags, + fetchMailProvider, fetchTemplates, fetchTranslations, saveConfiguration, } from './configuration'; +import * as fetchWrapperModule from './fetch-wrapper'; + +vi.mock('./fetch-wrapper', () => ({ + fetchWrapper: vi.fn(), + fetchJson: vi.fn(), +})); describe('fetchConfiguration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch configuration data and return as text', async () => { const mockResponse = 'Configuration data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const target = '#main'; const language = 'en'; const result = await fetchConfiguration(target, language); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/list/main`, { + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith(`./api/configuration/list/main`, { headers: { 'Accept-Language': language, }, @@ -35,11 +45,10 @@ describe('fetchConfiguration', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const target = '#main'; const language = 'en'; @@ -49,28 +58,32 @@ describe('fetchConfiguration', () => { }); describe('fetchFaqsSortingKeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch FAQs sorting keys and return as text', async () => { const mockResponse = 'Sorting keys data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchFaqsSortingKeys(currentValue); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/faqs-sorting-key/${currentValue}`); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith( + `./api/configuration/faqs-sorting-key/${currentValue}` + ); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchFaqsSortingKeys(currentValue); @@ -80,28 +93,32 @@ describe('fetchFaqsSortingKeys', () => { }); describe('fetchFaqsSortingPopular', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch FAQs sorting popular data and return as text', async () => { const mockResponse = 'Popular sorting data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchFaqsSortingPopular(currentValue); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/faqs-sorting-popular/${currentValue}`); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith( + `./api/configuration/faqs-sorting-popular/${currentValue}` + ); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchFaqsSortingPopular(currentValue); @@ -111,28 +128,30 @@ describe('fetchFaqsSortingPopular', () => { }); describe('fetchPermLevel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch permission level data and return as text', async () => { const mockResponse = 'Permission level data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchPermLevel(currentValue); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/perm-level/${currentValue}`); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith(`./api/configuration/perm-level/${currentValue}`); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchPermLevel(currentValue); @@ -142,28 +161,32 @@ describe('fetchPermLevel', () => { }); describe('fetchReleaseEnvironment', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch release environment data and return as text', async () => { const mockResponse = 'Release environment data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchReleaseEnvironment(currentValue); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/release-environment/${currentValue}`); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith( + `./api/configuration/release-environment/${currentValue}` + ); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchReleaseEnvironment(currentValue); @@ -173,28 +196,32 @@ describe('fetchReleaseEnvironment', () => { }); describe('fetchSearchRelevance', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch search relevance data and return as text', async () => { const mockResponse = 'Search relevance data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchSearchRelevance(currentValue); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/search-relevance/${currentValue}`); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith( + `./api/configuration/search-relevance/${currentValue}` + ); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchSearchRelevance(currentValue); @@ -204,28 +231,30 @@ describe('fetchSearchRelevance', () => { }); describe('fetchSeoMetaTags', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch SEO meta tags data and return as text', async () => { const mockResponse = 'SEO meta tags data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchSeoMetaTags(currentValue); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/configuration/seo-metatags/${currentValue}`); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith(`./api/configuration/seo-metatags/${currentValue}`); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const currentValue = 'someValue'; const result = await fetchSeoMetaTags(currentValue); @@ -235,27 +264,29 @@ describe('fetchSeoMetaTags', () => { }); describe('fetchTemplates', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch templates data and return as text', async () => { const mockResponse = 'Templates data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const result = await fetchTemplates(); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('./api/configuration/templates'); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/configuration/templates'); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const result = await fetchTemplates(); @@ -263,28 +294,63 @@ describe('fetchTemplates', () => { }); }); +describe('fetchMailProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch mail provider data and return as text', async () => { + const mockResponse = 'Mail provider data'; + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); + + const currentValue = 'smtp'; + const result = await fetchMailProvider(currentValue); + + expect(result).toBe(mockResponse); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith(`./api/configuration/mail-provider/${currentValue}`); + }); + + it('should return an empty string if the network response is not ok', async () => { + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); + + const currentValue = 'smtp'; + const result = await fetchMailProvider(currentValue); + + expect(result).toBe(''); + }); +}); + describe('fetchTranslations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch translations data and return as text', async () => { const mockResponse = 'Translations data'; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - text: () => Promise.resolve(mockResponse), - } as Response) - ); + const mockResponseObj = { + ok: true, + text: () => Promise.resolve(mockResponse), + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const result = await fetchTranslations(); expect(result).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('./api/configuration/translations'); + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/configuration/translations'); }); it('should return an empty string if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: false, - } as Response) - ); + const mockResponseObj = { + ok: false, + } as Response; + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponseObj); const result = await fetchTranslations(); @@ -293,14 +359,13 @@ describe('fetchTranslations', () => { }); describe('saveConfiguration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should save configuration and return JSON response if successful', async () => { const mockResponse = { success: true, data: 'Configuration saved' }; - global.fetch = vi.fn(() => - Promise.resolve({ - success: true, - json: () => Promise.resolve(mockResponse), - } as unknown as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const formData = new FormData(); formData.append('key', 'value'); @@ -308,7 +373,7 @@ describe('saveConfiguration', () => { const result = await saveConfiguration(formData); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('api/configuration', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/configuration', { method: 'POST', body: formData, }); diff --git a/phpmyfaq/admin/assets/src/api/configuration.ts b/phpmyfaq/admin/assets/src/api/configuration.ts index 20d097460d..1cca798924 100644 --- a/phpmyfaq/admin/assets/src/api/configuration.ts +++ b/phpmyfaq/admin/assets/src/api/configuration.ts @@ -14,9 +14,10 @@ */ import { Response } from '../interfaces'; +import { fetchWrapper, fetchJson } from './fetch-wrapper'; export const fetchConfiguration = async (target: string, language: string): Promise => { - const response = await fetch(`./api/configuration/list/${target.substring(1)}`, { + const response = await fetchWrapper(`./api/configuration/list/${target.substring(1)}`, { headers: { 'Accept-Language': language, }, @@ -30,7 +31,7 @@ export const fetchConfiguration = async (target: string, language: string): Prom }; export const fetchFaqsSortingKeys = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/faqs-sorting-key/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/faqs-sorting-key/${currentValue}`); if (!response.ok) { return ''; @@ -40,7 +41,7 @@ export const fetchFaqsSortingKeys = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/faqs-sorting-order/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/faqs-sorting-order/${currentValue}`); if (!response.ok) { return ''; @@ -50,7 +51,7 @@ export const fetchFaqsSortingOrder = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/faqs-sorting-popular/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/faqs-sorting-popular/${currentValue}`); if (!response.ok) { return ''; @@ -60,7 +61,7 @@ export const fetchFaqsSortingPopular = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/perm-level/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/perm-level/${currentValue}`); if (!response.ok) { return ''; @@ -70,7 +71,7 @@ export const fetchPermLevel = async (currentValue: string): Promise => { }; export const fetchReleaseEnvironment = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/release-environment/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/release-environment/${currentValue}`); if (!response.ok) { return ''; @@ -80,7 +81,7 @@ export const fetchReleaseEnvironment = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/search-relevance/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/search-relevance/${currentValue}`); if (!response.ok) { return ''; @@ -90,7 +91,27 @@ export const fetchSearchRelevance = async (currentValue: string): Promise => { - const response = await fetch(`./api/configuration/seo-metatags/${currentValue}`); + const response = await fetchWrapper(`./api/configuration/seo-metatags/${currentValue}`); + + if (!response.ok) { + return ''; + } + + return await response.text(); +}; + +export const fetchTranslationProvider = async (currentValue: string): Promise => { + const response = await fetchWrapper(`./api/configuration/translation-provider/${currentValue}`); + + if (!response.ok) { + return ''; + } + + return await response.text(); +}; + +export const fetchMailProvider = async (currentValue: string): Promise => { + const response = await fetchWrapper(`./api/configuration/mail-provider/${currentValue}`); if (!response.ok) { return ''; @@ -100,7 +121,7 @@ export const fetchSeoMetaTags = async (currentValue: string): Promise => }; export const fetchTemplates = async (): Promise => { - const response = await fetch(`./api/configuration/templates`); + const response = await fetchWrapper(`./api/configuration/templates`); if (!response.ok) { return ''; @@ -110,7 +131,7 @@ export const fetchTemplates = async (): Promise => { }; export const fetchTranslations = async (): Promise => { - const response = await fetch(`./api/configuration/translations`); + const response = await fetchWrapper(`./api/configuration/translations`); if (!response.ok) { return ''; @@ -119,11 +140,16 @@ export const fetchTranslations = async (): Promise => { return await response.text(); }; -export const saveConfiguration = async (data: FormData): Promise => { - const response = (await fetch('api/configuration', { +export const saveConfiguration = async (data: FormData): Promise => { + return (await fetchJson('api/configuration', { method: 'POST', body: data, - })) as unknown as Response; + })) as Response; +}; - return await response.json(); +export const uploadThemeArchive = async (data: FormData): Promise => { + return (await fetchJson('api/configuration/themes/upload', { + method: 'POST', + body: data, + })) as Response; }; diff --git a/phpmyfaq/admin/assets/src/api/elasticsearch.ts b/phpmyfaq/admin/assets/src/api/elasticsearch.ts index 0525412fbe..e22986bd11 100644 --- a/phpmyfaq/admin/assets/src/api/elasticsearch.ts +++ b/phpmyfaq/admin/assets/src/api/elasticsearch.ts @@ -14,9 +14,10 @@ */ import { ElasticsearchResponse, Response } from '../interfaces'; +import { fetchWrapper, fetchJson } from './fetch-wrapper'; export const fetchElasticsearchAction = async (action: string): Promise => { - const response = await fetch(`./api/elasticsearch/${action}`, { + return (await fetchJson(`./api/elasticsearch/${action}`, { method: 'GET', cache: 'no-cache', headers: { @@ -24,13 +25,11 @@ export const fetchElasticsearchAction = async (action: string): Promise => { - const response = await fetch('./api/elasticsearch/statistics', { + return (await fetchJson('./api/elasticsearch/statistics', { method: 'GET', cache: 'no-cache', headers: { @@ -38,9 +37,7 @@ export const fetchElasticsearchStatistics = async (): Promise => { @@ -48,7 +45,7 @@ export const fetchElasticsearchHealthcheck = async (timeoutMs: number = 5000): P const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch('./api/elasticsearch/healthcheck', { + const response = await fetchWrapper('./api/elasticsearch/healthcheck', { method: 'GET', cache: 'no-cache', headers: { diff --git a/phpmyfaq/admin/assets/src/api/export.ts b/phpmyfaq/admin/assets/src/api/export.ts index d80d67385a..8f3ad88771 100644 --- a/phpmyfaq/admin/assets/src/api/export.ts +++ b/phpmyfaq/admin/assets/src/api/export.ts @@ -14,10 +14,11 @@ */ import { Response } from '../interfaces'; +import { fetchWrapper } from './fetch-wrapper'; export const createReport = async (data: unknown, csrfToken: string): Promise => { try { - const response = await fetch('./api/export/report', { + const response = await fetchWrapper('./api/export/report', { method: 'POST', cache: 'no-cache', headers: { diff --git a/phpmyfaq/admin/assets/src/api/faqs.test.ts b/phpmyfaq/admin/assets/src/api/faqs.test.ts index 23d6842869..c18fb54f57 100644 --- a/phpmyfaq/admin/assets/src/api/faqs.test.ts +++ b/phpmyfaq/admin/assets/src/api/faqs.test.ts @@ -38,17 +38,20 @@ describe('fetchFaqsByAutocomplete', () => { expect(global.fetch).toHaveBeenCalled(); }); - test('should throw an error if the network response is not ok', async () => { + test('should return error JSON for non-ok response', async () => { + const mockError = { error: 'Server error', status: 500 }; global.fetch = vi.fn(() => Promise.resolve({ status: 500, + json: () => Promise.resolve(mockError), } as Response) ); const searchTerm = 'faq'; const csrfToken = 'csrfToken'; - await expect(fetchFaqsByAutocomplete(searchTerm, csrfToken)).rejects.toThrow('Network response was not ok.'); + const result = await fetchFaqsByAutocomplete(searchTerm, csrfToken); + expect(result).toEqual(mockError); }); }); @@ -71,10 +74,12 @@ describe('deleteFaq', () => { expect(global.fetch).toHaveBeenCalled(); }); - test('should throw an error if the network response is not ok', async () => { + test('should return error JSON for non-ok response', async () => { + const mockError = { error: 'Failed to delete', status: 500 }; global.fetch = vi.fn(() => Promise.resolve({ status: 500, + json: () => Promise.resolve(mockError), } as Response) ); @@ -82,7 +87,8 @@ describe('deleteFaq', () => { const faqLanguage = 'en'; const csrfToken = 'csrfToken'; - await expect(deleteFaq(faqId, faqLanguage, csrfToken)).rejects.toThrow('Network response was not ok.'); + const result = await deleteFaq(faqId, faqLanguage, csrfToken); + expect(result).toEqual(mockError); }); }); diff --git a/phpmyfaq/admin/assets/src/api/faqs.ts b/phpmyfaq/admin/assets/src/api/faqs.ts index 32b0f25d56..93375e666c 100644 --- a/phpmyfaq/admin/assets/src/api/faqs.ts +++ b/phpmyfaq/admin/assets/src/api/faqs.ts @@ -13,15 +13,14 @@ * @since 2023-12-27 */ -import { Response } from '../interfaces'; -import { FaqList } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; export const fetchAllFaqsByCategory = async ( categoryId: string, language: string, onlyInactive?: boolean, onlyNew?: boolean -): Promise => { +): Promise => { let currentUrl: string = window.location.protocol + '//' + window.location.host; let pathname: string = window.location.pathname; @@ -37,7 +36,7 @@ export const fetchAllFaqsByCategory = async ( if (onlyNew) { url.searchParams.set('only-new', onlyNew as unknown as string); } - const response = await fetch(url.toString(), { + return await fetchJson(url.toString(), { method: 'GET', cache: 'no-cache', headers: { @@ -46,12 +45,10 @@ export const fetchAllFaqsByCategory = async ( redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; -export const fetchFaqsByAutocomplete = async (searchTerm: string, csrfToken: string): Promise => { - const response = await fetch(`./api/faq/search`, { +export const fetchFaqsByAutocomplete = async (searchTerm: string, csrfToken: string): Promise => { + return await fetchJson(`./api/faq/search`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -62,16 +59,10 @@ export const fetchFaqsByAutocomplete = async (searchTerm: string, csrfToken: str csrf: csrfToken, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; -export const deleteFaq = async (faqId: string, faqLanguage: string, token: string): Promise => { - const response = await fetch('./api/faq/delete', { +export const deleteFaq = async (faqId: string, faqLanguage: string, token: string): Promise => { + return await fetchJson('./api/faq/delete', { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', @@ -83,16 +74,10 @@ export const deleteFaq = async (faqId: string, faqLanguage: string, token: strin faqLanguage: faqLanguage, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; -export const create = async (formData: unknown): Promise => { - const response = await fetch('./api/faq/create', { +export const create = async (formData: unknown): Promise => { + return await fetchJson('./api/faq/create', { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -102,12 +87,10 @@ export const create = async (formData: unknown): Promise = data: formData, }), }); - - return await response.json(); }; -export const update = async (formData: unknown): Promise => { - const response = await fetch('./api/faq/update', { +export const update = async (formData: unknown): Promise => { + return await fetchJson('./api/faq/update', { method: 'PUT', headers: { Accept: 'application/json, text/plain, */*', @@ -117,6 +100,4 @@ export const update = async (formData: unknown): Promise = data: formData, }), }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts b/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts new file mode 100644 index 0000000000..8168b854ad --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchWrapper, fetchJson } from './fetch-wrapper'; + +describe('fetchWrapper', () => { + let originalFetch: typeof globalThis.fetch; + let originalLocation: Location; + let sessionStorageMock: { [key: string]: string }; + + beforeEach(() => { + // Save original fetch + originalFetch = globalThis.fetch; + + // Save the original location + originalLocation = window.location; + + // Mock sessionStorage + sessionStorageMock = {}; + const sessionStorageMockImpl = { + getItem: vi.fn((key: string) => sessionStorageMock[key] || null), + setItem: vi.fn((key: string, value: string) => { + sessionStorageMock[key] = value; + }), + removeItem: vi.fn((key: string) => { + Reflect.deleteProperty(sessionStorageMock, key); + }), + clear: vi.fn(() => { + sessionStorageMock = {}; + }), + }; + + Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMockImpl, + writable: true, + }); + + // Mock window.location.href + Object.defineProperty(window, 'location', { + value: { ...originalLocation, href: '' }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; + + // Restore original location + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + + vi.clearAllMocks(); + }); + + describe('successful responses', () => { + it('should return response for 200 status', async () => { + const mockResponse = new Response(JSON.stringify({ data: 'test' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const response = await fetchWrapper('/test', { method: 'GET' }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: 'test' }); + }); + + it('should return response for 201 status', async () => { + const mockResponse = new Response(JSON.stringify({ created: true }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const response = await fetchWrapper('/test', { method: 'POST' }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ created: true }); + }); + + it('should pass through all fetch options', async () => { + const mockResponse = new Response('OK', { status: 200 }); + const fetchSpy = vi.fn().mockResolvedValue(mockResponse); + globalThis.fetch = fetchSpy; + + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test: 'data' }), + }; + + await fetchWrapper('/api/test', options); + + expect(fetchSpy).toHaveBeenCalledWith('/api/test', options); + }); + }); + + describe('401 Unauthorized handling', () => { + it('should store session timeout message in sessionStorage on 401', async () => { + const mockResponse = new Response('Unauthorized', { status: 401 }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + try { + await fetchWrapper('/test', { method: 'GET' }); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Session expired'); + } + + expect(sessionStorageMock['loginMessage']).toBe('Your session has expired. Please log in again.'); + }); + + it('should redirect to login page on 401', async () => { + const mockResponse = new Response('Unauthorized', { status: 401 }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + try { + await fetchWrapper('/test', { method: 'GET' }); + expect.fail('Should have thrown an error'); + } catch { + expect(window.location.href).toBe('./admin/login'); + } + }); + + it('should throw error to stop further processing on 401', async () => { + const mockResponse = new Response('Unauthorized', { status: 401 }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + await expect(fetchWrapper('/test', { method: 'GET' })).rejects.toThrow('Session expired'); + }); + + it('should handle 401 from POST request', async () => { + const mockResponse = new Response('Unauthorized', { status: 401 }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const options = { + method: 'POST', + body: JSON.stringify({ data: 'test' }), + }; + + try { + await fetchWrapper('/api/save', options); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Session expired'); + expect(sessionStorageMock['loginMessage']).toBe('Your session has expired. Please log in again.'); + expect(window.location.href).toBe('./admin/login'); + } + }); + }); + + describe('other error responses', () => { + it('should return response for 400 Bad Request', async () => { + const mockResponse = new Response(JSON.stringify({ error: 'Bad request' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const response = await fetchWrapper('/test', { method: 'GET' }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Bad request' }); + }); + + it('should return response for 403 Forbidden', async () => { + const mockResponse = new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const response = await fetchWrapper('/test', { method: 'GET' }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'Forbidden' }); + }); + + it('should return response for 404 Not Found', async () => { + const mockResponse = new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const response = await fetchWrapper('/test', { method: 'GET' }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'Not found' }); + }); + + it('should return response for 500 Internal Server Error', async () => { + const mockResponse = new Response(JSON.stringify({ error: 'Server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const response = await fetchWrapper('/test', { method: 'GET' }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Server error' }); + }); + }); +}); + +describe('fetchJson', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.clearAllMocks(); + }); + + it('should fetch and parse JSON for successful response', async () => { + const mockData = { message: 'Success', data: [1, 2, 3] }; + const mockResponse = new Response(JSON.stringify(mockData), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await fetchJson('/api/test', { method: 'GET' }); + + expect(result).toEqual(mockData); + }); + + it('should handle POST request with JSON body', async () => { + const mockData = { id: 123 }; + const mockResponse = new Response(JSON.stringify(mockData), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const requestBody = { name: 'Test' }; + const result = await fetchJson('/api/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + expect(result).toEqual(mockData); + }); + + it('should parse empty response as null', async () => { + const mockResponse = new Response('null', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await fetchJson('/api/test'); + + expect(result).toBeNull(); + }); + + it('should parse array response', async () => { + const mockData = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const mockResponse = new Response(JSON.stringify(mockData), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await fetchJson('/api/list'); + + expect(result).toEqual(mockData); + }); + + it('should handle error responses and still parse JSON', async () => { + const mockError = { error: 'Validation failed', fields: ['name', 'email'] }; + const mockResponse = new Response(JSON.stringify(mockError), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await fetchJson('/api/validate'); + + expect(result).toEqual(mockError); + }); +}); diff --git a/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts b/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts new file mode 100644 index 0000000000..731c4cc58b --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts @@ -0,0 +1,52 @@ +/** + * Fetch wrapper with automatic 401 handling + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-17 + */ + +/** + * Wrapper around fetch that handles 401 responses globally + * When a 401 is encountered (session timeout), the user is redirected to log in + * + * @param url The URL to fetch + * @param options Fetch options + * @returns Promise + */ +export const fetchWrapper = async (url: string, options?: RequestInit): Promise => { + const response = await fetch(url, options); + + // Handle 401 Unauthorized - session timeout + if (response.status === 401) { + // Store a flash message in sessionStorage to show after redirect + sessionStorage.setItem('loginMessage', 'Your session has expired. Please log in again.'); + + // Redirect to the login page + window.location.href = './admin/login'; + + // Throw error to stop further processing + throw new Error('Session expired'); + } + + return response; +}; + +/** + * JSON wrapper that uses fetchWrapper and returns parsed JSON + * + * @param url The URL to fetch + * @param options Fetch options + * @returns Promise with parsed JSON + */ +export const fetchJson = async (url: string, options?: RequestInit): Promise => { + const response = await fetchWrapper(url, options); + return await response.json(); +}; diff --git a/phpmyfaq/admin/assets/src/api/forms.test.ts b/phpmyfaq/admin/assets/src/api/forms.test.ts index 24dd247aff..2177b0db42 100644 --- a/phpmyfaq/admin/assets/src/api/forms.test.ts +++ b/phpmyfaq/admin/assets/src/api/forms.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchActivateInput, fetchSetInputAsRequired, @@ -6,16 +6,20 @@ import { fetchDeleteTranslation, fetchAddTranslation, } from './forms'; +import * as fetchWrapperModule from './fetch-wrapper'; + +vi.mock('./fetch-wrapper', () => ({ + fetchJson: vi.fn(), +})); describe('fetchActivateInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should activate input and return JSON response if successful', async () => { const mockResponse = { success: true }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const csrf = 'csrfToken'; const formId = 'formId'; @@ -24,10 +28,9 @@ describe('fetchActivateInput', () => { const result = await fetchActivateInput(csrf, formId, inputId, checked); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('api/forms/activate', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/forms/activate', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -40,11 +43,7 @@ describe('fetchActivateInput', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const csrf = 'csrfToken'; const formId = 'formId'; @@ -56,14 +55,13 @@ describe('fetchActivateInput', () => { }); describe('fetchSetInputAsRequired', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should set input as required and return JSON response if successful', async () => { const mockResponse = { success: true }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const csrf = 'csrfToken'; const formId = 'formId'; @@ -72,10 +70,9 @@ describe('fetchSetInputAsRequired', () => { const result = await fetchSetInputAsRequired(csrf, formId, inputId, checked); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('api/forms/required', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/forms/required', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -88,11 +85,7 @@ describe('fetchSetInputAsRequired', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const csrf = 'csrfToken'; const formId = 'formId'; @@ -106,14 +99,13 @@ describe('fetchSetInputAsRequired', () => { }); describe('fetchEditTranslation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should edit translation and return JSON response if successful', async () => { const mockResponse = { success: true }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const csrf = 'csrfToken'; const formId = 'formId'; @@ -123,10 +115,9 @@ describe('fetchEditTranslation', () => { const result = await fetchEditTranslation(csrf, formId, inputId, label, lang); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('api/forms/translation-edit', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/forms/translation-edit', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -140,11 +131,7 @@ describe('fetchEditTranslation', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const csrf = 'csrfToken'; const formId = 'formId'; @@ -159,14 +146,13 @@ describe('fetchEditTranslation', () => { }); describe('fetchDeleteTranslation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should delete translation and return JSON response if successful', async () => { const mockResponse = { success: true }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const csrf = 'csrfToken'; const formId = 'formId'; @@ -175,10 +161,9 @@ describe('fetchDeleteTranslation', () => { const result = await fetchDeleteTranslation(csrf, formId, inputId, lang); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('api/forms/translation-delete', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/forms/translation-delete', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -191,11 +176,7 @@ describe('fetchDeleteTranslation', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const csrf = 'csrfToken'; const formId = 'formId'; @@ -207,14 +188,13 @@ describe('fetchDeleteTranslation', () => { }); describe('fetchAddTranslation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should add translation and return JSON response if successful', async () => { const mockResponse = { success: true }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const csrf = 'csrfToken'; const formId = 'formId'; @@ -224,10 +204,9 @@ describe('fetchAddTranslation', () => { const result = await fetchAddTranslation(csrf, formId, inputId, lang, translation); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('api/forms/translation-add', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/forms/translation-add', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -241,11 +220,7 @@ describe('fetchAddTranslation', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const csrf = 'csrfToken'; const formId = 'formId'; diff --git a/phpmyfaq/admin/assets/src/api/forms.ts b/phpmyfaq/admin/assets/src/api/forms.ts index 3cf7a62609..e1ef94efce 100644 --- a/phpmyfaq/admin/assets/src/api/forms.ts +++ b/phpmyfaq/admin/assets/src/api/forms.ts @@ -14,16 +14,17 @@ * @since 2014-03-21 */ +import { fetchJson } from './fetch-wrapper'; + export const fetchActivateInput = async ( csrf: string, formId: string, inputId: string, checked: boolean ): Promise => { - const response = await fetch('api/forms/activate', { + return await fetchJson('api/forms/activate', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -33,12 +34,6 @@ export const fetchActivateInput = async ( checked: checked, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; export const fetchSetInputAsRequired = async ( @@ -47,10 +42,9 @@ export const fetchSetInputAsRequired = async ( inputId: string, checked: boolean ): Promise => { - const response = await fetch('api/forms/required', { + return await fetchJson('api/forms/required', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -60,12 +54,6 @@ export const fetchSetInputAsRequired = async ( checked: checked, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; export const fetchEditTranslation = async ( @@ -75,10 +63,9 @@ export const fetchEditTranslation = async ( label: string, lang: string ): Promise => { - const response = await fetch('api/forms/translation-edit', { + return await fetchJson('api/forms/translation-edit', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -89,12 +76,6 @@ export const fetchEditTranslation = async ( label: label, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; export const fetchDeleteTranslation = async ( @@ -103,10 +84,9 @@ export const fetchDeleteTranslation = async ( inputId: string, lang: string ): Promise => { - const response = await fetch('api/forms/translation-delete', { + return await fetchJson('api/forms/translation-delete', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -116,12 +96,6 @@ export const fetchDeleteTranslation = async ( lang: lang, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; export const fetchAddTranslation = async ( @@ -131,10 +105,9 @@ export const fetchAddTranslation = async ( lang: string, translation: string ): Promise => { - const response = await fetch('api/forms/translation-add', { + return await fetchJson('api/forms/translation-add', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -145,10 +118,4 @@ export const fetchAddTranslation = async ( translation: translation, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; diff --git a/phpmyfaq/admin/assets/src/api/glossary.test.ts b/phpmyfaq/admin/assets/src/api/glossary.test.ts index eb571af53a..b6e538598c 100644 --- a/phpmyfaq/admin/assets/src/api/glossary.test.ts +++ b/phpmyfaq/admin/assets/src/api/glossary.test.ts @@ -33,10 +33,12 @@ describe('createGlossary', () => { }); }); - it('should throw an error if the network response is not ok', async () => { + it('should return error JSON for non-ok response', async () => { + const mockError = { error: 'Failed to create glossary', status: 500 }; global.fetch = vi.fn(() => Promise.resolve({ status: 500, + json: () => Promise.resolve(mockError), } as Response) ); @@ -45,7 +47,8 @@ describe('createGlossary', () => { const definition = 'definition'; const csrfToken = 'csrfToken'; - await expect(createGlossary(language, item, definition, csrfToken)).rejects.toThrow('Network response was not ok.'); + const result = await createGlossary(language, item, definition, csrfToken); + expect(result).toEqual(mockError); }); }); @@ -79,10 +82,12 @@ describe('deleteGlossary', () => { }); }); - it('should throw an error if the network response is not ok', async () => { + it('should return error JSON for non-ok response', async () => { + const mockError = { error: 'Failed to delete glossary', status: 500 }; global.fetch = vi.fn(() => Promise.resolve({ status: 500, + json: () => Promise.resolve(mockError), } as Response) ); @@ -90,7 +95,8 @@ describe('deleteGlossary', () => { const glossaryLang = 'en'; const csrfToken = 'csrfToken'; - await expect(deleteGlossary(glossaryId, glossaryLang, csrfToken)).rejects.toThrow('Network response was not ok.'); + const result = await deleteGlossary(glossaryId, glossaryLang, csrfToken); + expect(result).toEqual(mockError); }); }); @@ -118,17 +124,20 @@ describe('getGlossary', () => { }); }); - it('should throw an error if the network response is not ok', async () => { + it('should return error JSON for non-ok response', async () => { + const mockError = { error: 'Glossary not found', status: 404 }; global.fetch = vi.fn(() => Promise.resolve({ - status: 500, + status: 404, + json: () => Promise.resolve(mockError), } as Response) ); const glossaryId = '123'; const glossaryLanguage = 'en'; - await expect(getGlossary(glossaryId, glossaryLanguage)).rejects.toThrow('Network response was not ok.'); + const result = await getGlossary(glossaryId, glossaryLanguage); + expect(result).toEqual(mockError); }); }); @@ -166,10 +175,12 @@ describe('updateGlossary', () => { }); }); - it('should throw an error if the network response is not ok', async () => { + it('should return error JSON for non-ok response', async () => { + const mockError = { error: 'Failed to update glossary', status: 500 }; global.fetch = vi.fn(() => Promise.resolve({ status: 500, + json: () => Promise.resolve(mockError), } as Response) ); @@ -179,8 +190,7 @@ describe('updateGlossary', () => { const definition = 'definition'; const csrfToken = 'csrfToken'; - await expect(updateGlossary(glossaryId, glossaryLanguage, item, definition, csrfToken)).rejects.toThrow( - 'Network response was not ok.' - ); + const result = await updateGlossary(glossaryId, glossaryLanguage, item, definition, csrfToken); + expect(result).toEqual(mockError); }); }); diff --git a/phpmyfaq/admin/assets/src/api/glossary.ts b/phpmyfaq/admin/assets/src/api/glossary.ts index e2c5050a25..5d92488733 100644 --- a/phpmyfaq/admin/assets/src/api/glossary.ts +++ b/phpmyfaq/admin/assets/src/api/glossary.ts @@ -13,15 +13,15 @@ * @since 2024-01-27 */ -import { Response, GlossaryResponse } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; export const createGlossary = async ( language: string, item: string, definition: string, csrfToken: string -): Promise => { - const response = await fetch('./api/glossary/create', { +): Promise => { + return await fetchJson('./api/glossary/create', { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -34,20 +34,10 @@ export const createGlossary = async ( definition: definition, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; -export const deleteGlossary = async ( - glossaryId: string, - glossaryLang: string, - csrfToken: string -): Promise => { - const response = await fetch('./api/glossary/delete', { +export const deleteGlossary = async (glossaryId: string, glossaryLang: string, csrfToken: string): Promise => { + return await fetchJson('./api/glossary/delete', { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', @@ -59,31 +49,16 @@ export const deleteGlossary = async ( lang: glossaryLang, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; -export const getGlossary = async ( - glossaryId: string, - glossaryLanguage: string -): Promise => { - const response = await fetch(`./api/glossary/${glossaryId}/${glossaryLanguage}`, { +export const getGlossary = async (glossaryId: string, glossaryLanguage: string): Promise => { + return await fetchJson(`./api/glossary/${glossaryId}/${glossaryLanguage}`, { method: 'GET', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; export const updateGlossary = async ( @@ -92,8 +67,8 @@ export const updateGlossary = async ( item: string, definition: string, csrfToken: string -): Promise => { - const response = await fetch('./api/glossary/update', { +): Promise => { + return await fetchJson('./api/glossary/update', { method: 'PUT', headers: { Accept: 'application/json, text/plain, */*', @@ -107,10 +82,4 @@ export const updateGlossary = async ( definition: definition, }), }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); }; diff --git a/phpmyfaq/admin/assets/src/api/group.test.ts b/phpmyfaq/admin/assets/src/api/group.test.ts index f3d2a9dc85..7f3ce2024f 100644 --- a/phpmyfaq/admin/assets/src/api/group.test.ts +++ b/phpmyfaq/admin/assets/src/api/group.test.ts @@ -1,20 +1,24 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchAllGroups, fetchAllUsersForGroups, fetchAllMembers, fetchGroup, fetchGroupRights } from './group'; +import * as fetchWrapperModule from './fetch-wrapper'; + +vi.mock('./fetch-wrapper', () => ({ + fetchJson: vi.fn(), +})); describe('fetchAllGroups', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch all groups and return JSON response if successful', async () => { const mockResponse = { success: true, data: 'Groups data' }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await fetchAllGroups(); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('./api/group/groups', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/group/groups', { method: 'GET', cache: 'no-cache', headers: { @@ -26,30 +30,25 @@ describe('fetchAllGroups', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); await expect(fetchAllGroups()).rejects.toThrow('Network response was not ok.'); }); }); describe('fetchAllUsersForGroups', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch all users for groups and return JSON response if successful', async () => { const mockResponse = { success: true, data: 'Users data' }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await fetchAllUsersForGroups(); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('./api/group/users', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/group/users', { method: 'GET', cache: 'no-cache', headers: { @@ -61,31 +60,26 @@ describe('fetchAllUsersForGroups', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); await expect(fetchAllUsersForGroups()).rejects.toThrow('Network response was not ok.'); }); }); describe('fetchAllMembers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch all members of a group and return JSON response if successful', async () => { const mockResponse = { success: true, data: 'Members data' }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const groupId = '123'; const result = await fetchAllMembers(groupId); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/group/members/${groupId}`, { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith(`./api/group/members/${groupId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -97,11 +91,7 @@ describe('fetchAllMembers', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const groupId = '123'; @@ -110,20 +100,19 @@ describe('fetchAllMembers', () => { }); describe('fetchGroup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch a group and return JSON response if successful', async () => { const mockResponse = { success: true, data: 'Group data' }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const groupId = '123'; const result = await fetchGroup(groupId); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/group/data/${groupId}`, { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith(`./api/group/data/${groupId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -135,11 +124,7 @@ describe('fetchGroup', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const groupId = '123'; @@ -148,20 +133,19 @@ describe('fetchGroup', () => { }); describe('fetchGroupRights', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should fetch group rights and return JSON response if successful', async () => { const mockResponse = { success: true, data: 'Group rights data' }; - global.fetch = vi.fn(() => - Promise.resolve({ - status: 200, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const groupId = '123'; const result = await fetchGroupRights(groupId); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith(`./api/group/permissions/${groupId}`, { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith(`./api/group/permissions/${groupId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -173,11 +157,7 @@ describe('fetchGroupRights', () => { }); it('should throw an error if the network response is not ok', async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - status: 500, - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.')); const groupId = '123'; diff --git a/phpmyfaq/admin/assets/src/api/group.ts b/phpmyfaq/admin/assets/src/api/group.ts index a33cf86d5d..67ecb87a89 100644 --- a/phpmyfaq/admin/assets/src/api/group.ts +++ b/phpmyfaq/admin/assets/src/api/group.ts @@ -14,9 +14,10 @@ */ import { Group, Member, User } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; export const fetchAllGroups = async (): Promise => { - const response = await fetch('./api/group/groups', { + return (await fetchJson('./api/group/groups', { method: 'GET', cache: 'no-cache', headers: { @@ -24,17 +25,11 @@ export const fetchAllGroups = async (): Promise => { }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); + })) as Group[]; }; export const fetchAllUsersForGroups = async (): Promise => { - const response = await fetch('./api/group/users', { + return (await fetchJson('./api/group/users', { method: 'GET', cache: 'no-cache', headers: { @@ -42,17 +37,11 @@ export const fetchAllUsersForGroups = async (): Promise => { }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); + })) as User[]; }; export const fetchAllMembers = async (groupId: string): Promise => { - const response = await fetch(`./api/group/members/${groupId}`, { + return (await fetchJson(`./api/group/members/${groupId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -60,17 +49,11 @@ export const fetchAllMembers = async (groupId: string): Promise => { }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); + })) as Member[]; }; export const fetchGroup = async (groupId: string): Promise => { - const response = await fetch(`./api/group/data/${groupId}`, { + return (await fetchJson(`./api/group/data/${groupId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -78,17 +61,11 @@ export const fetchGroup = async (groupId: string): Promise => { }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); + })) as Group; }; export const fetchGroupRights = async (groupId: string): Promise => { - const response = await fetch(`./api/group/permissions/${groupId}`, { + return (await fetchJson(`./api/group/permissions/${groupId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -96,11 +73,5 @@ export const fetchGroupRights = async (groupId: string): Promise => { }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - if (response.status === 200) { - return await response.json(); - } - - throw new Error('Network response was not ok.'); + })) as string[]; }; diff --git a/phpmyfaq/admin/assets/src/api/index.ts b/phpmyfaq/admin/assets/src/api/index.ts index d7e8270a1e..69c3796f02 100644 --- a/phpmyfaq/admin/assets/src/api/index.ts +++ b/phpmyfaq/admin/assets/src/api/index.ts @@ -12,6 +12,8 @@ export * from './markdown'; export * from './media-browser'; export * from './news'; export * from './opensearch'; +export * from './push'; +export * from './page'; export * from './question'; export * from './statistics'; export * from './stop-words'; diff --git a/phpmyfaq/admin/assets/src/api/instance.ts b/phpmyfaq/admin/assets/src/api/instance.ts index fd1d52f0fb..8632d76f1d 100644 --- a/phpmyfaq/admin/assets/src/api/instance.ts +++ b/phpmyfaq/admin/assets/src/api/instance.ts @@ -13,7 +13,7 @@ * @since 2025-01-26 */ -import { InstanceResponse } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; export const addInstance = async ( csrf: string, @@ -23,8 +23,8 @@ export const addInstance = async ( email: string, admin: string, password: string -): Promise => { - const response = await fetch(`./api/faq/search`, { +): Promise => { + return await fetchJson(`./api/faq/search`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -40,12 +40,10 @@ export const addInstance = async ( password: password, }), }); - - return await response.json(); }; -export const deleteInstance = async (csrf: string, instanceId: string): Promise => { - const response = await fetch('./api/instance/delete', { +export const deleteInstance = async (csrf: string, instanceId: string): Promise => { + return await fetchJson('./api/instance/delete', { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', @@ -56,6 +54,4 @@ export const deleteInstance = async (csrf: string, instanceId: string): Promise< instanceId: instanceId, }), }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/markdown.ts b/phpmyfaq/admin/assets/src/api/markdown.ts index ed2e2149fd..454c0791e7 100644 --- a/phpmyfaq/admin/assets/src/api/markdown.ts +++ b/phpmyfaq/admin/assets/src/api/markdown.ts @@ -12,17 +12,14 @@ * @link https://www.phpmyfaq.de * @since 2025-03-03 */ -import { Response } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; export const fetchMarkdownContent = async (text: string): Promise => { - const response = await fetch(`./api/content/markdown`, { + return (await fetchJson(`./api/content/markdown`, { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ text }), - }); - - return await response.json(); + })) as Response; }; diff --git a/phpmyfaq/admin/assets/src/api/media-browser.ts b/phpmyfaq/admin/assets/src/api/media-browser.ts index 8ba01e6ece..0756b3fc17 100644 --- a/phpmyfaq/admin/assets/src/api/media-browser.ts +++ b/phpmyfaq/admin/assets/src/api/media-browser.ts @@ -14,16 +14,14 @@ */ import { MediaBrowserApiResponse } from '../interfaces/'; +import { fetchJson } from './fetch-wrapper'; export const fetchMediaBrowserContent = async (): Promise => { - const response = await fetch(`./api/media-browser`, { + return (await fetchJson(`./api/media-browser`, { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'files' }), - }); - - return await response.json(); + })) as MediaBrowserApiResponse; }; diff --git a/phpmyfaq/admin/assets/src/api/news.ts b/phpmyfaq/admin/assets/src/api/news.ts index e12c540cba..efff51ef23 100644 --- a/phpmyfaq/admin/assets/src/api/news.ts +++ b/phpmyfaq/admin/assets/src/api/news.ts @@ -14,8 +14,10 @@ * @since 2024-04-21 */ +import { fetchJson } from './fetch-wrapper'; + export const addNews = async (data: Record = {}): Promise => { - const response = await fetch('api/news/create', { + return await fetchJson('api/news/create', { method: 'POST', cache: 'no-cache', headers: { @@ -25,12 +27,10 @@ export const addNews = async (data: Record = {}): Promise => { - const response = await fetch('api/news/delete', { + return await fetchJson('api/news/delete', { method: 'DELETE', cache: 'no-cache', headers: { @@ -43,12 +43,10 @@ export const deleteNews = async (csrfToken: string, id: string): Promise = {}): Promise => { - const response = await fetch('api/news/update', { + return await fetchJson('api/news/update', { method: 'PUT', cache: 'no-cache', headers: { @@ -58,12 +56,10 @@ export const updateNews = async (data: Record = {}): Promise => { - const response = await fetch('api/news/activate', { + return await fetchJson('api/news/activate', { method: 'POST', cache: 'no-cache', headers: { @@ -77,6 +73,4 @@ export const activateNews = async (id: string, status: string, csrfToken: string csrfToken: csrfToken, }), }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/opensearch.ts b/phpmyfaq/admin/assets/src/api/opensearch.ts index 3cb7f2a726..459ac7e31c 100644 --- a/phpmyfaq/admin/assets/src/api/opensearch.ts +++ b/phpmyfaq/admin/assets/src/api/opensearch.ts @@ -14,9 +14,10 @@ */ import { ElasticsearchResponse, Response } from '../interfaces'; +import { fetchWrapper, fetchJson } from './fetch-wrapper'; export const fetchOpenSearchAction = async (action: string): Promise => { - const response = await fetch(`./api/opensearch/${action}`, { + return (await fetchJson(`./api/opensearch/${action}`, { method: 'GET', cache: 'no-cache', headers: { @@ -24,13 +25,11 @@ export const fetchOpenSearchAction = async (action: string): Promise = }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - return await response.json(); + })) as Response; }; export const fetchOpenSearchStatistics = async (): Promise => { - const response = await fetch('./api/opensearch/statistics', { + return (await fetchJson('./api/opensearch/statistics', { method: 'GET', cache: 'no-cache', headers: { @@ -38,9 +37,7 @@ export const fetchOpenSearchStatistics = async (): Promise => { @@ -48,7 +45,7 @@ export const fetchOpenSearchHealthcheck = async (timeoutMs: number = 5000): Prom const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch('./api/opensearch/healthcheck', { + const response = await fetchWrapper('./api/opensearch/healthcheck', { method: 'GET', cache: 'no-cache', headers: { diff --git a/phpmyfaq/admin/assets/src/api/page.test.ts b/phpmyfaq/admin/assets/src/api/page.test.ts new file mode 100644 index 0000000000..dde764a594 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/page.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { addPage, deletePage, updatePage, activatePage, checkSlug } from './page'; +import * as fetchWrapperModule from './fetch-wrapper'; + +vi.mock('./fetch-wrapper', () => ({ + fetchJson: vi.fn(), +})); + +describe('Page API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('addPage', () => { + it('should add a page and return JSON response if successful', async () => { + const mockResponse = { success: true, id: '123' }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const data = { + title: 'Test Page', + content: 'Test content', + lang: 'en', + }; + + const result = await addPage(data); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/create', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify(data), + }); + }); + + it('should handle empty data object', async () => { + const mockResponse = { success: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await addPage(); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/create', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({}), + }); + }); + + it('should throw an error if the request fails', async () => { + const mockError = new Error('Failed to add page'); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(addPage({ title: 'Test' })).rejects.toThrow('Failed to add page'); + }); + }); + + describe('deletePage', () => { + it('should delete a page and return JSON response if successful', async () => { + const mockResponse = { success: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const csrfToken = 'token123'; + const id = '456'; + const lang = 'en'; + + const result = await deletePage(csrfToken, id, lang); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/delete', { + method: 'DELETE', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + csrfToken: csrfToken, + id: id, + lang: lang, + }), + }); + }); + + it('should throw an error if the request fails', async () => { + const mockError = new Error('Failed to delete page'); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(deletePage('token', '123', 'en')).rejects.toThrow('Failed to delete page'); + }); + }); + + describe('updatePage', () => { + it('should update a page and return JSON response if successful', async () => { + const mockResponse = { success: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const data = { + id: '123', + title: 'Updated Page', + content: 'Updated content', + lang: 'en', + }; + + const result = await updatePage(data); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/update', { + method: 'PUT', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify(data), + }); + }); + + it('should handle empty data object', async () => { + const mockResponse = { success: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await updatePage(); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/update', { + method: 'PUT', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({}), + }); + }); + + it('should throw an error if the request fails', async () => { + const mockError = new Error('Failed to update page'); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(updatePage({ id: '123' })).rejects.toThrow('Failed to update page'); + }); + }); + + describe('activatePage', () => { + it('should activate a page and return JSON response if successful', async () => { + const mockResponse = { success: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const id = '123'; + const status = true; + const csrfToken = 'token123'; + + const result = await activatePage(id, status, csrfToken); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/activate', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + id: id, + status: status, + csrfToken: csrfToken, + }), + }); + }); + + it('should deactivate a page when status is false', async () => { + const mockResponse = { success: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const id = '123'; + const status = false; + const csrfToken = 'token123'; + + const result = await activatePage(id, status, csrfToken); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/activate', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + id: id, + status: status, + csrfToken: csrfToken, + }), + }); + }); + + it('should throw an error if the request fails', async () => { + const mockError = new Error('Failed to activate page'); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(activatePage('123', true, 'token')).rejects.toThrow('Failed to activate page'); + }); + }); + + describe('checkSlug', () => { + it('should check slug availability and return JSON response if successful', async () => { + const mockResponse = { available: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const slug = 'test-page'; + const lang = 'en'; + const csrfToken = 'token123'; + + const result = await checkSlug(slug, lang, csrfToken); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/check-slug', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + slug: slug, + lang: lang, + csrfToken: csrfToken, + excludeId: undefined, + }), + }); + }); + + it('should check slug with excludeId parameter', async () => { + const mockResponse = { available: true }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const slug = 'test-page'; + const lang = 'en'; + const csrfToken = 'token123'; + const excludeId = '456'; + + const result = await checkSlug(slug, lang, csrfToken, excludeId); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/page/check-slug', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + slug: slug, + lang: lang, + csrfToken: csrfToken, + excludeId: excludeId, + }), + }); + }); + + it('should return slug not available when it already exists', async () => { + const mockResponse = { available: false, message: 'Slug already exists' }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await checkSlug('existing-slug', 'en', 'token'); + + expect(result).toEqual(mockResponse); + expect((result as { available: boolean }).available).toBe(false); + }); + + it('should throw an error if the request fails', async () => { + const mockError = new Error('Failed to check slug'); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(checkSlug('test', 'en', 'token')).rejects.toThrow('Failed to check slug'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/api/page.ts b/phpmyfaq/admin/assets/src/api/page.ts new file mode 100644 index 0000000000..d64a149451 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/page.ts @@ -0,0 +1,99 @@ +/** + * Fetch data for custom pages + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-15 + */ + +import { fetchJson } from './fetch-wrapper'; + +export const addPage = async (data: Record = {}): Promise => { + return await fetchJson('api/page/create', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify(data), + }); +}; + +export const deletePage = async (csrfToken: string, id: string, lang: string): Promise => { + return await fetchJson('api/page/delete', { + method: 'DELETE', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + csrfToken: csrfToken, + id: id, + lang: lang, + }), + }); +}; + +export const updatePage = async (data: Record = {}): Promise => { + return await fetchJson('api/page/update', { + method: 'PUT', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify(data), + }); +}; + +export const activatePage = async (id: string, status: boolean, csrfToken: string): Promise => { + return await fetchJson('api/page/activate', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + id: id, + status: status, + csrfToken: csrfToken, + }), + }); +}; + +export const checkSlug = async ( + slug: string, + lang: string, + csrfToken: string, + excludeId?: string +): Promise => { + return await fetchJson('api/page/check-slug', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + slug: slug, + lang: lang, + csrfToken: csrfToken, + excludeId: excludeId, + }), + }); +}; diff --git a/phpmyfaq/admin/assets/src/api/push.test.ts b/phpmyfaq/admin/assets/src/api/push.test.ts new file mode 100644 index 0000000000..8b6c4c5979 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/push.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fetchGenerateVapidKeys } from './push'; + +describe('Push API', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchGenerateVapidKeys', () => { + it('should generate VAPID keys and return JSON response if successful', async () => { + const mockResponse = { success: true, publicKey: 'BPubKey123' }; + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + const result = await fetchGenerateVapidKeys('csrf-token'); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith('./api/push/generate-vapid-keys', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ csrf: 'csrf-token' }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + it('should throw an error if fetch fails', async () => { + const mockError = new Error('Fetch failed'); + globalThis.fetch = vi.fn(() => Promise.reject(mockError)); + + await expect(fetchGenerateVapidKeys('csrf-token')).rejects.toThrow(mockError); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/api/push.ts b/phpmyfaq/admin/assets/src/api/push.ts new file mode 100644 index 0000000000..f28c8728d0 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/push.ts @@ -0,0 +1,35 @@ +/** + * Fetch data for Web Push configuration + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-04 + */ + +import { fetchJson } from './fetch-wrapper'; + +export interface VapidKeysResponse { + success: boolean; + publicKey: string; + error?: string; +} + +export const fetchGenerateVapidKeys = async (csrfToken: string): Promise => { + return (await fetchJson('./api/push/generate-vapid-keys', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ csrf: csrfToken }), + })) as VapidKeysResponse; +}; diff --git a/phpmyfaq/admin/assets/src/api/question.ts b/phpmyfaq/admin/assets/src/api/question.ts index 0c82dafc81..5b74235eaa 100644 --- a/phpmyfaq/admin/assets/src/api/question.ts +++ b/phpmyfaq/admin/assets/src/api/question.ts @@ -14,13 +14,14 @@ */ import { Response } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; export const toggleQuestionVisibility = async ( questionId: string, visibility: boolean, csrfToken: string ): Promise => { - const response = await fetch(`./api/question/visibility/toggle`, { + return (await fetchJson(`./api/question/visibility/toggle`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -30,7 +31,5 @@ export const toggleQuestionVisibility = async ( visibility: visibility, csrfToken: csrfToken, }), - }); - - return await response.json(); + })) as Response; }; diff --git a/phpmyfaq/admin/assets/src/api/statistics.ts b/phpmyfaq/admin/assets/src/api/statistics.ts index 81298f5f3a..02bfb0c1e7 100644 --- a/phpmyfaq/admin/assets/src/api/statistics.ts +++ b/phpmyfaq/admin/assets/src/api/statistics.ts @@ -13,10 +13,10 @@ * @since 2024-04-21 */ -import { Response } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; -export const deleteAdminLog = async (csrfToken: string): Promise => { - const response = await fetch(`./api/statistics/admin-log`, { +export const deleteAdminLog = async (csrfToken: string): Promise => { + return await fetchJson(`./api/statistics/admin-log`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -28,12 +28,10 @@ export const deleteAdminLog = async (csrfToken: string): Promise => { - const response = await fetch(`./api/statistics/search-terms`, { +export const truncateSearchTerms = async (csrfToken: string): Promise => { + return await fetchJson(`./api/statistics/search-terms`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -45,12 +43,10 @@ export const truncateSearchTerms = async (csrfToken: string): Promise => { - const response = await fetch(`./api/statistics/ratings/clear`, { +export const clearRatings = async (csrfToken: string): Promise => { + return await fetchJson(`./api/statistics/ratings/clear`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -62,12 +58,10 @@ export const clearRatings = async (csrfToken: string): Promise => { - const response = await fetch(`./api/statistics/visits/clear`, { +export const clearVisits = async (csrfToken: string): Promise => { + return await fetchJson(`./api/statistics/visits/clear`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -79,12 +73,10 @@ export const clearVisits = async (csrfToken: string): Promise => { - const response = await fetch(`./api/statistics/sessions`, { +export const deleteSessions = async (csrfToken: string, month: string): Promise => { + return await fetchJson(`./api/statistics/sessions`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -97,6 +89,4 @@ export const deleteSessions = async (csrfToken: string, month: string): Promise< redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/sticky-faqs.test.ts b/phpmyfaq/admin/assets/src/api/sticky-faqs.test.ts new file mode 100644 index 0000000000..9402de2253 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/sticky-faqs.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { updateStickyFaqsOrder, removeStickyFaq } from './sticky-faqs'; +import * as fetchWrapperModule from './fetch-wrapper'; + +// Mock the fetch-wrapper module +vi.mock('./fetch-wrapper', () => ({ + fetchJson: vi.fn(), +})); + +describe('sticky-faqs API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('updateStickyFaqsOrder', () => { + it('should update sticky FAQ order successfully', async () => { + const mockResponse = { + success: 'Order updated successfully', + }; + + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const faqIds = ['1', '2', '3']; + const csrf = 'test-csrf-token'; + + const result = await updateStickyFaqsOrder(faqIds, csrf); + + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/faqs/sticky/order', { + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + faqIds, + csrf, + }), + }); + + expect(result).toEqual(mockResponse); + }); + + it('should handle empty FAQ IDs array', async () => { + const mockResponse = { + success: 'Order updated', + }; + + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await updateStickyFaqsOrder([], 'csrf-token'); + + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith( + './api/faqs/sticky/order', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + faqIds: [], + csrf: 'csrf-token', + }), + }) + ); + + expect(result).toEqual(mockResponse); + }); + + it('should handle API error response', async () => { + const mockError = new Error('API Error'); + + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(updateStickyFaqsOrder(['1'], 'csrf-token')).rejects.toThrow('API Error'); + }); + + it('should send correct request body format', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue({ success: 'OK' }); + + await updateStickyFaqsOrder(['10', '20', '30'], 'my-csrf'); + + const callArgs = vi.mocked(fetchWrapperModule.fetchJson).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1]?.body as string); + + expect(requestBody).toEqual({ + faqIds: ['10', '20', '30'], + csrf: 'my-csrf', + }); + }); + }); + + describe('removeStickyFaq', () => { + it('should remove sticky FAQ successfully', async () => { + const mockResponse = { + success: 'FAQ removed from sticky list', + }; + + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const faqId = '42'; + const categoryId = '5'; + const csrfToken = 'test-csrf-token'; + const lang = 'en'; + + const result = await removeStickyFaq(faqId, categoryId, csrfToken, lang); + + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/faq/sticky', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-PMF-CSRF-Token': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + csrf: csrfToken, + categoryId: categoryId, + faqIds: [faqId], + faqLanguage: lang, + checked: false, + }), + }); + + expect(result).toEqual(mockResponse); + }); + + it('should include correct headers', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue({ success: 'OK' }); + + await removeStickyFaq('1', '2', 'my-token', 'de'); + + const callArgs = vi.mocked(fetchWrapperModule.fetchJson).mock.calls[0]; + const headers = callArgs[1]?.headers as Record; + + expect(headers['X-PMF-CSRF-Token']).toBe('my-token'); + expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['Accept']).toBe('application/json'); + }); + + it('should send faqId as array in request body', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue({ success: 'OK' }); + + await removeStickyFaq('123', '456', 'csrf', 'fr'); + + const callArgs = vi.mocked(fetchWrapperModule.fetchJson).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1]?.body as string); + + expect(requestBody.faqIds).toEqual(['123']); + expect(Array.isArray(requestBody.faqIds)).toBe(true); + }); + + it('should set checked to false', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue({ success: 'OK' }); + + await removeStickyFaq('1', '2', 'csrf', 'en'); + + const callArgs = vi.mocked(fetchWrapperModule.fetchJson).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1]?.body as string); + + expect(requestBody.checked).toBe(false); + }); + + it('should handle API error response', async () => { + const mockError = new Error('Network error'); + + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); + + await expect(removeStickyFaq('1', '2', 'csrf', 'en')).rejects.toThrow('Network error'); + }); + + it('should handle different language codes', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue({ success: 'OK' }); + + await removeStickyFaq('1', '2', 'csrf', 'de'); + + const callArgs = vi.mocked(fetchWrapperModule.fetchJson).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1]?.body as string); + + expect(requestBody.faqLanguage).toBe('de'); + }); + + it('should send all required parameters in request body', async () => { + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue({ success: 'OK' }); + + const faqId = '999'; + const categoryId = '888'; + const csrf = 'token-123'; + const lang = 'es'; + + await removeStickyFaq(faqId, categoryId, csrf, lang); + + const callArgs = vi.mocked(fetchWrapperModule.fetchJson).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1]?.body as string); + + expect(requestBody).toEqual({ + csrf: csrf, + categoryId: categoryId, + faqIds: [faqId], + faqLanguage: lang, + checked: false, + }); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/api/sticky-faqs.ts b/phpmyfaq/admin/assets/src/api/sticky-faqs.ts new file mode 100644 index 0000000000..625ce58aba --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/sticky-faqs.ts @@ -0,0 +1,78 @@ +/** + * API functions for sticky FAQs management + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-18 + */ + +import { fetchJson } from './fetch-wrapper'; + +export interface StickyOrderResponse { + success?: string; + error?: string; +} + +export interface RemoveStickyResponse { + success?: string; + error?: string; +} + +/** + * Update the order of sticky FAQs + * @param faqIds Array of FAQ IDs in the new order + * @param csrf CSRF token + * @returns Promise with the API response + */ +export const updateStickyFaqsOrder = async (faqIds: string[], csrf: string): Promise => { + return (await fetchJson('./api/faqs/sticky/order', { + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + faqIds, + csrf, + }), + })) as StickyOrderResponse; +}; + +/** + * Remove a sticky FAQ + * @param faqId FAQ ID to remove from sticky + * @param categoryId Category ID + * @param csrfToken CSRF token + * @param lang Language code + * @returns Promise with the API response + */ +export const removeStickyFaq = async ( + faqId: string, + categoryId: string, + csrfToken: string, + lang: string +): Promise => { + return (await fetchJson('./api/faq/sticky', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-PMF-CSRF-Token': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + csrf: csrfToken, + categoryId: categoryId, + faqIds: [faqId], + faqLanguage: lang, + checked: false, + }), + })) as RemoveStickyResponse; +}; diff --git a/phpmyfaq/admin/assets/src/api/stop-words.test.ts b/phpmyfaq/admin/assets/src/api/stop-words.test.ts index 0e34dece75..b7d309dcf9 100644 --- a/phpmyfaq/admin/assets/src/api/stop-words.test.ts +++ b/phpmyfaq/admin/assets/src/api/stop-words.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect, vi, Mock } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { fetchByLanguage, postStopWord, removeStopWord } from './stop-words'; +import * as fetchWrapperModule from './fetch-wrapper'; -global.fetch = vi.fn(); +vi.mock('./fetch-wrapper', () => ({ + fetchJson: vi.fn(), +})); describe('Stop Words API', () => { afterEach(() => { @@ -10,16 +13,12 @@ describe('Stop Words API', () => { it('fetchByLanguage should fetch stop words by language', async () => { const mockResponse = [{ id: 1, lang: 'en', stopword: 'example' }]; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await fetchByLanguage('en'); - expect(fetch).toHaveBeenCalledWith('./api/stopwords?language=en', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/stopwords?language=en', { method: 'GET', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -28,16 +27,12 @@ describe('Stop Words API', () => { it('postStopWord should post a new stop word', async () => { const mockResponse = { success: true }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await postStopWord('csrfToken', 'example', 1, 'en'); - expect(fetch).toHaveBeenCalledWith('./api/stopword/save', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/stopword/save', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -52,16 +47,12 @@ describe('Stop Words API', () => { it('removeStopWord should delete a stop word', async () => { const mockResponse = { success: true }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await removeStopWord('csrfToken', 1, 'en'); - expect(fetch).toHaveBeenCalledWith('./api/stopword/delete', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/stopword/delete', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ diff --git a/phpmyfaq/admin/assets/src/api/stop-words.ts b/phpmyfaq/admin/assets/src/api/stop-words.ts index 6706f75a93..f2e9eb2e38 100644 --- a/phpmyfaq/admin/assets/src/api/stop-words.ts +++ b/phpmyfaq/admin/assets/src/api/stop-words.ts @@ -13,16 +13,15 @@ * @since 2025-02-08 */ +import { fetchJson } from './fetch-wrapper'; + export const fetchByLanguage = async (language: string): Promise => { - const response = await fetch(`./api/stopwords?language=${language}`, { + return await fetchJson(`./api/stopwords?language=${language}`, { method: 'GET', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); - - return await response.json(); }; export const postStopWord = async ( @@ -31,10 +30,9 @@ export const postStopWord = async ( stopWordId: number, stopWordLanguage: string ): Promise => { - const response = await fetch('./api/stopword/save', { + return await fetchJson('./api/stopword/save', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -44,15 +42,12 @@ export const postStopWord = async ( stopWordsLang: stopWordLanguage, }), }); - - return await response.json(); }; export const removeStopWord = async (csrf: string, stopWordId: number, stopWordLanguage: string): Promise => { - const response = await fetch('./api/stopword/delete', { + return await fetchJson('./api/stopword/delete', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -61,6 +56,4 @@ export const removeStopWord = async (csrf: string, stopWordId: number, stopWordL stopWordsLang: stopWordLanguage, }), }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/tags.ts b/phpmyfaq/admin/assets/src/api/tags.ts index 2c1cde2de8..75549ec64a 100644 --- a/phpmyfaq/admin/assets/src/api/tags.ts +++ b/phpmyfaq/admin/assets/src/api/tags.ts @@ -13,13 +13,10 @@ * @since 2023-04-12 */ -interface TagResponse { - id: string; - name: string; -} +import { fetchJson } from './fetch-wrapper'; -export const fetchTags = async (searchString: string): Promise => { - const response = await fetch(`./api/content/tags?search=${searchString}`, { +export const fetchTags = async (searchString: string): Promise => { + return await fetchJson(`./api/content/tags?search=${searchString}`, { method: 'GET', cache: 'no-cache', headers: { @@ -28,12 +25,10 @@ export const fetchTags = async (searchString: string): Promise => redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; -export const deleteTag = async (tagId: string): Promise<{ success?: string; error?: string }> => { - const response = await fetch(`./api/content/tags/${tagId}`, { +export const deleteTag = async (tagId: string): Promise => { + return await fetchJson(`./api/content/tags/${tagId}`, { method: 'DELETE', cache: 'no-cache', headers: { @@ -42,6 +37,4 @@ export const deleteTag = async (tagId: string): Promise<{ success?: string; erro redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; diff --git a/phpmyfaq/admin/assets/src/api/upgrade.test.ts b/phpmyfaq/admin/assets/src/api/upgrade.test.ts index 108fddf233..2854642335 100644 --- a/phpmyfaq/admin/assets/src/api/upgrade.test.ts +++ b/phpmyfaq/admin/assets/src/api/upgrade.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach, Mock } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { fetchHealthCheck, activateMaintenanceMode, @@ -9,24 +9,25 @@ import { startInstallation, startDatabaseUpdate, } from './upgrade'; +import * as fetchWrapperModule from './fetch-wrapper'; -global.fetch = vi.fn(); +vi.mock('./fetch-wrapper', () => ({ + fetchJson: vi.fn(), + fetchWrapper: vi.fn(), +})); describe('Upgrade API', (): void => { afterEach((): void => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); it('fetchHealthCheck should fetch health check and return JSON response if successful', async (): Promise => { const mockResponse = { success: 'true', message: 'Health check passed' }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await fetchHealthCheck(); expect(result).toEqual(mockResponse); - expect(fetch).toHaveBeenCalledWith('./api/health-check', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/health-check', { method: 'GET', cache: 'no-cache', headers: { @@ -39,17 +40,13 @@ describe('Upgrade API', (): void => { it('activateMaintenanceMode should activate maintenance mode', async (): Promise => { const mockResponse = { success: 'true' }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await activateMaintenanceMode('csrfToken'); expect(result).toEqual(mockResponse); - expect(fetch).toHaveBeenCalledWith('./api/configuration/activate-maintenance-mode', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/configuration/activate-maintenance-mode', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ csrf: 'csrfToken' }), @@ -58,17 +55,13 @@ describe('Upgrade API', (): void => { it('checkForUpdates should check for updates', async (): Promise => { const mockResponse = { success: 'true', version: '1.0.0' }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await checkForUpdates(); expect(result).toEqual(mockResponse); - expect(fetch).toHaveBeenCalledWith('./api/update-check', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/update-check', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -76,17 +69,13 @@ describe('Upgrade API', (): void => { it('downloadPackage should download a package', async (): Promise => { const mockResponse = { success: 'true' }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await downloadPackage('1.0.0'); expect(result).toEqual(mockResponse); - expect(fetch).toHaveBeenCalledWith('./api/download-package/1.0.0', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/download-package/1.0.0', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -94,17 +83,13 @@ describe('Upgrade API', (): void => { it('extractPackage should extract a package', async (): Promise => { const mockResponse = { success: 'true' }; - (fetch as Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const result = await extractPackage(); expect(result).toEqual(mockResponse); - expect(fetch).toHaveBeenCalledWith('./api/extract-package', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/extract-package', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -114,12 +99,12 @@ describe('Upgrade API', (): void => { const mockResponse = new Response(JSON.stringify({ progress: '50%' }), { headers: { 'Content-Type': 'application/json' }, }); - (fetch as Mock).mockResolvedValue(mockResponse); + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse); + await startTemporaryBackup(); - expect(fetch).toHaveBeenCalledWith('./api/create-temporary-backup', { + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/create-temporary-backup', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -129,12 +114,12 @@ describe('Upgrade API', (): void => { const mockResponse = new Response(JSON.stringify({ progress: '50%' }), { headers: { 'Content-Type': 'application/json' }, }); - (fetch as Mock).mockResolvedValue(mockResponse); + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse); + await startInstallation(); - expect(fetch).toHaveBeenCalledWith('./api/install-package', { + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/install-package', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -144,12 +129,12 @@ describe('Upgrade API', (): void => { const mockResponse = new Response(JSON.stringify({ progress: '50%' }), { headers: { 'Content-Type': 'application/json' }, }); - (fetch as Mock).mockResolvedValue(mockResponse); + vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse); + await startDatabaseUpdate(); - expect(fetch).toHaveBeenCalledWith('./api/update-database', { + expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/update-database', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); diff --git a/phpmyfaq/admin/assets/src/api/upgrade.ts b/phpmyfaq/admin/assets/src/api/upgrade.ts index d4b135b1b0..41d3921d8f 100644 --- a/phpmyfaq/admin/assets/src/api/upgrade.ts +++ b/phpmyfaq/admin/assets/src/api/upgrade.ts @@ -13,6 +13,8 @@ * @since 2024-05-30 */ +import { fetchWrapper, fetchJson } from './fetch-wrapper'; + interface ResponseData { success?: string; warning?: string; @@ -23,7 +25,7 @@ interface ResponseData { } export const fetchHealthCheck = async (): Promise => { - const response: Response = await fetch(`./api/health-check`, { + return (await fetchJson(`./api/health-check`, { method: 'GET', cache: 'no-cache', headers: { @@ -31,85 +33,68 @@ export const fetchHealthCheck = async (): Promise => { }, redirect: 'follow', referrerPolicy: 'no-referrer', - }); - - return await response.json(); + })) as ResponseData; }; export const activateMaintenanceMode = async (csrfToken: string): Promise => { - const response: Response = await fetch('./api/configuration/activate-maintenance-mode', { + return (await fetchJson('./api/configuration/activate-maintenance-mode', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ csrf: csrfToken }), - }); - - return await response.json(); + })) as ResponseData; }; export const checkForUpdates = async (): Promise => { - const response: Response = await fetch('./api/update-check', { + return (await fetchJson('./api/update-check', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, - }); - - return await response.json(); + })) as ResponseData; }; export const downloadPackage = async (version: string): Promise => { - const response: Response = await fetch(`./api/download-package/${version}`, { + return (await fetchJson(`./api/download-package/${version}`, { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, - }); - - return await response.json(); + })) as ResponseData; }; export const extractPackage = async (): Promise => { - const response: Response = await fetch('./api/extract-package', { + return (await fetchJson('./api/extract-package', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, - }); - - return await response.json(); + })) as ResponseData; }; export const startTemporaryBackup = async (): Promise => { - return await fetch('./api/create-temporary-backup', { + return await fetchWrapper('./api/create-temporary-backup', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); }; export const startInstallation = async (): Promise => { - return await fetch('./api/install-package', { + return await fetchWrapper('./api/install-package', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); }; export const startDatabaseUpdate = async (): Promise => { - return await fetch('./api/update-database', { + return await fetchWrapper('./api/update-database', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); diff --git a/phpmyfaq/admin/assets/src/api/user.ts b/phpmyfaq/admin/assets/src/api/user.ts index acc529b831..4e1ea5dc4b 100644 --- a/phpmyfaq/admin/assets/src/api/user.ts +++ b/phpmyfaq/admin/assets/src/api/user.ts @@ -13,10 +13,10 @@ * @since 2022-03-23 */ -import { Response, UserData } from '../interfaces'; +import { fetchJson } from './fetch-wrapper'; -export const fetchUsers = async (userName: string): Promise => { - const response = await fetch(`./api/user/users?filter=${userName}`, { +export const fetchUsers = async (userName: string): Promise => { + return await fetchJson(`./api/user/users?filter=${userName}`, { method: 'GET', cache: 'no-cache', headers: { @@ -25,12 +25,10 @@ export const fetchUsers = async (userName: string): Promise => { redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; -export const fetchUserData = async (userId: string): Promise => { - const response = await fetch(`./api/user/data/${userId}`, { +export const fetchUserData = async (userId: string): Promise => { + return await fetchJson(`./api/user/data/${userId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -39,12 +37,10 @@ export const fetchUserData = async (userId: string): Promise => { redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; -export const fetchUserRights = async (userId: string): Promise => { - const response = await fetch(`./api/user/permissions/${userId}`, { +export const fetchUserRights = async (userId: string): Promise => { + return await fetchJson(`./api/user/permissions/${userId}`, { method: 'GET', cache: 'no-cache', headers: { @@ -53,12 +49,10 @@ export const fetchUserRights = async (userId: string): Promise => { redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; -export const fetchAllUsers = async (): Promise => { - const response = await fetch('./api/user/users', { +export const fetchAllUsers = async (): Promise => { + return await fetchJson('./api/user/users', { method: 'GET', cache: 'no-cache', headers: { @@ -67,8 +61,6 @@ export const fetchAllUsers = async (): Promise => { redirect: 'follow', referrerPolicy: 'no-referrer', }); - - return await response.json(); }; export const overwritePassword = async ( @@ -76,8 +68,8 @@ export const overwritePassword = async ( userId: string, newPassword: string, passwordRepeat: string -): Promise => { - const response = await fetch('./api/user/overwrite-password', { +): Promise => { + return await fetchJson('./api/user/overwrite-password', { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -90,12 +82,10 @@ export const overwritePassword = async ( passwordRepeat: passwordRepeat, }), }); - - return await response.json(); }; -export const postUserData = async (url: string = '', data: Record = {}): Promise => { - const response = await fetch(url, { +export const postUserData = async (url: string = '', data: Record = {}): Promise => { + return await fetchJson(url, { method: 'POST', cache: 'no-cache', headers: { @@ -105,12 +95,10 @@ export const postUserData = async (url: string = '', data: Record => { - const response = await fetch('./api/user/activate', { +export const activateUser = async (userId: string, csrfToken: string): Promise => { + return await fetchJson('./api/user/activate', { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', @@ -121,12 +109,10 @@ export const activateUser = async (userId: string, csrfToken: string): Promise => { - const response = await fetch('./api/user/delete', { +export const deleteUser = async (userId: string, csrfToken: string): Promise => { + return await fetchJson('./api/user/delete', { method: 'DELETE', cache: 'no-cache', headers: { @@ -139,6 +125,4 @@ export const deleteUser = async (userId: string, csrfToken: string): Promise ({ + fetchJson: vi.fn(), +})); describe('Verification API', () => { afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); describe('getRemoteHashes', () => { it('should fetch remote hashes and return JSON response if successful', async () => { const mockResponse = { 'file1.js': 'hash1', 'file2.js': 'hash2' }; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const version = '1.0.0'; const result = await getRemoteHashes(version); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('https://api.phpmyfaq.de/verify/1.0.0', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('https://api.phpmyfaq.de/verify/1.0.0', { method: 'GET', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, }); @@ -31,7 +30,7 @@ describe('Verification API', () => { it('should throw an error if fetch fails', async () => { const mockError = new Error('Fetch failed'); - global.fetch = vi.fn(() => Promise.reject(mockError)); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); const version = '1.0.0'; @@ -42,21 +41,15 @@ describe('Verification API', () => { describe('verifyHashes', () => { it('should verify hashes and return JSON response if successful', async () => { const mockResponse = { success: true, message: 'Hashes verified' }; - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockResponse), - } as Response) - ); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); const remoteHashes = { 'file1.js': 'hash1', 'file2.js': 'hash2' }; const result = await verifyHashes(remoteHashes); expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith('./api/dashboard/verify', { + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/dashboard/verify', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify(remoteHashes), @@ -65,7 +58,7 @@ describe('Verification API', () => { it('should throw an error if fetch fails', async () => { const mockError = new Error('Fetch failed'); - global.fetch = vi.fn(() => Promise.reject(mockError)); + vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(mockError); const remoteHashes = { 'file1.js': 'hash1', 'file2.js': 'hash2' }; diff --git a/phpmyfaq/admin/assets/src/api/verification.ts b/phpmyfaq/admin/assets/src/api/verification.ts index 2be95bc067..5408647582 100644 --- a/phpmyfaq/admin/assets/src/api/verification.ts +++ b/phpmyfaq/admin/assets/src/api/verification.ts @@ -13,6 +13,8 @@ * @since 2024-07-09 */ +import { fetchJson } from './fetch-wrapper'; + interface RemoteHashes { [key: string]: string; } @@ -22,26 +24,20 @@ interface VerificationResult { } export const getRemoteHashes = async (version: string): Promise => { - const response = await fetch(`https://api.phpmyfaq.de/verify/${version}`, { + return (await fetchJson(`https://api.phpmyfaq.de/verify/${version}`, { method: 'GET', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, - }); - - return await response.json(); + })) as RemoteHashes | undefined; }; export const verifyHashes = async (remoteHashes: RemoteHashes): Promise => { - const response = await fetch('./api/dashboard/verify', { + return (await fetchJson('./api/dashboard/verify', { method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify(remoteHashes), - }); - - return await response.json(); + })) as VerificationResult; }; diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.test.ts b/phpmyfaq/admin/assets/src/configuration/configuration.test.ts index 98beb0ab6c..56326d32d7 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.test.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { handleConfiguration, + handleConfigurationTabFiltering, handleSaveConfiguration, handleSMTPPasswordToggle, handleTranslation, @@ -12,6 +13,7 @@ import { handleReleaseEnvironment, handleSearchRelevance, handleSeoMetaTags, + handleMailProvider, } from './configuration'; import { fetchConfiguration, @@ -22,6 +24,7 @@ import { fetchReleaseEnvironment, fetchSearchRelevance, fetchSeoMetaTags, + fetchMailProvider, fetchTemplates, fetchTranslations, saveConfiguration, @@ -203,13 +206,34 @@ describe('Configuration Functions', () => { }); }); + describe('handleMailProvider', () => { + it('should fetch and insert mail provider options', async () => { + document.body.innerHTML = ` + + `; + + (fetchMailProvider as Mock).mockResolvedValue(''); + + await handleMailProvider(); + + const selectBox = document.querySelector('select[name="edit[mail.provider]"]'); + expect(selectBox?.innerHTML).toContain(''); + }); + }); + describe('handleConfiguration', () => { it('should handle configuration tabs and load data', async () => { document.body.innerHTML = ` -
- - -
+
+
    + + +
+
@@ -225,4 +249,145 @@ describe('Configuration Functions', () => { expect(fetchConfiguration).toHaveBeenCalled(); }); }); + + describe('handleConfigurationTabFiltering', () => { + const buildFilterDOM = (): void => { + document.body.innerHTML = ` + + +
    +
  • Core
  • + +
  • Maintenance
  • + +
+
+
Default language
+
FAQ title
+
+
+ +
+
+
+
+
+
SMTP server address
+
SMTP password
+
+
+
+
Release environment
+
+
+
+
+ `; + }; + + const triggerFilterAndFlush = async (query: string): Promise => { + const filterInput = document.getElementById('pmf-configuration-tab-filter') as HTMLInputElement; + filterInput.value = query; + filterInput.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(300); + await vi.runAllTimersAsync(); + }; + + it('should filter tabs by tab label and hide non-matching groups', async () => { + vi.useFakeTimers(); + buildFilterDOM(); + + (fetchConfiguration as Mock).mockResolvedValue(''); + + handleConfigurationTabFiltering(); + + await triggerFilterAndFlush('main'); + + const mainItem = document.querySelector('li.nav-item[data-config-label="Main"]'); + const upgradeItem = document.querySelector('li.nav-item[data-config-label="Upgrade"]'); + const coreGroup = document.querySelector('li.pmf-configuration-group[data-config-group="core"]'); + const maintenanceGroup = document.querySelector('li.pmf-configuration-group[data-config-group="maintenance"]'); + + expect(mainItem?.classList.contains('d-none')).toBe(false); + expect(upgradeItem?.classList.contains('d-none')).toBe(true); + expect(coreGroup?.classList.contains('d-none')).toBe(false); + expect(maintenanceGroup?.classList.contains('d-none')).toBe(true); + + vi.useRealTimers(); + }); + + it('should show matching config items and hide non-matching ones', async () => { + vi.useFakeTimers(); + buildFilterDOM(); + + (fetchConfiguration as Mock).mockResolvedValue(''); + + handleConfigurationTabFiltering(); + + await triggerFilterAndFlush('smtp'); + + const smtpItem = document.querySelector('.pmf-config-item[data-config-key="mail.remoteSMTP"]'); + const smtpPasswordItem = document.querySelector('.pmf-config-item[data-config-key="mail.remoteSMTPPassword"]'); + const languageItem = document.querySelector('.pmf-config-item[data-config-key="main.language"]'); + + expect(smtpItem?.classList.contains('d-none')).toBe(false); + expect(smtpPasswordItem?.classList.contains('d-none')).toBe(false); + expect(languageItem?.classList.contains('d-none')).toBe(true); + + vi.useRealTimers(); + }); + + it('should hide tabs with zero matching items', async () => { + vi.useFakeTimers(); + buildFilterDOM(); + + (fetchConfiguration as Mock).mockResolvedValue(''); + + handleConfigurationTabFiltering(); + + await triggerFilterAndFlush('smtp'); + + const mainItem = document.querySelector('li.nav-item[data-config-label="Main"]'); + const upgradeItem = document.querySelector('li.nav-item[data-config-label="Upgrade"]'); + + expect(mainItem?.classList.contains('d-none')).toBe(true); + expect(upgradeItem?.classList.contains('d-none')).toBe(true); + + vi.useRealTimers(); + }); + + it('should restore all items and tabs when filter is cleared', async () => { + vi.useFakeTimers(); + buildFilterDOM(); + + (fetchConfiguration as Mock).mockResolvedValue(''); + + handleConfigurationTabFiltering(); + + await triggerFilterAndFlush('smtp'); + + await triggerFilterAndFlush(''); + + const allItems = document.querySelectorAll('.pmf-config-item'); + allItems.forEach((item) => { + expect(item.classList.contains('d-none')).toBe(false); + }); + + const allNavItems = document.querySelectorAll('li.nav-item[data-config-group]'); + allNavItems.forEach((item) => { + expect(item.classList.contains('d-none')).toBe(false); + }); + + const allGroupHeaders = document.querySelectorAll('li.pmf-configuration-group'); + allGroupHeaders.forEach((header) => { + expect(header.classList.contains('d-none')).toBe(false); + }); + + vi.useRealTimers(); + }); + }); }); diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.ts b/phpmyfaq/admin/assets/src/configuration/configuration.ts index 67e3d515a1..a7b539fd73 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.ts @@ -24,15 +24,83 @@ import { fetchReleaseEnvironment, fetchSearchRelevance, fetchSeoMetaTags, + fetchMailProvider, fetchTemplates, fetchTranslations, + fetchTranslationProvider, + uploadThemeArchive, saveConfiguration, } from '../api'; +import { handleWebPush } from './webpush'; import { Response } from '../interfaces'; +const TAB_TARGETS = [ + '#main', + '#records', + '#search', + '#security', + '#spam', + '#seo', + '#layout', + '#mail', + '#api', + '#upgrade', + '#translation', + '#push', + '#ldap', +]; + +let allTabsLoaded = false; + +const loadTabForSearch = async (target: string): Promise => { + const pane = document.querySelector(target) as HTMLElement | null; + if (!pane || pane.children.length > 0) { + return; + } + + const languageElement = document.getElementById('pmf-language') as HTMLInputElement | null; + if (!languageElement) { + return; + } + + const response = await fetchConfiguration(target, languageElement.value); + pane.innerHTML = response.toString(); +}; + +const ensureAllTabsLoaded = async (): Promise => { + if (allTabsLoaded) { + return; + } + + await Promise.all(TAB_TARGETS.map((target) => loadTabForSearch(target))); + allTabsLoaded = true; +}; + +const applyItemFilterToPane = (pane: HTMLElement, query: string): number => { + const items = Array.from(pane.querySelectorAll('.pmf-config-item')) as HTMLElement[]; + let matchCount = 0; + + items.forEach((item) => { + const text = (item.textContent || '').toLowerCase(); + const key = (item.dataset.configKey || '').toLowerCase(); + const matches = query === '' || text.includes(query) || key.includes(query); + item.classList.toggle('d-none', !matches); + if (matches) { + matchCount++; + } + }); + + return matchCount; +}; + export const handleConfiguration = async (): Promise => { - const configTabList: HTMLElement[] = [].slice.call(document.querySelectorAll('#configuration-list a')); + const configTabList: HTMLElement[] = [].slice.call( + document.querySelectorAll('#configuration-list .pmf-configuration-tabs a[data-bs-toggle="tab"]') + ); const result = document.getElementById('pmf-configuration-result') as HTMLElement; + + handleConfigurationTabFiltering(); + if (configTabList.length) { let tabLoaded: boolean = false; configTabList.forEach((element: HTMLElement): void => { @@ -48,6 +116,7 @@ export const handleConfiguration = async (): Promise => { break; case '#layout': await handleTemplates(); + await handleThemes(); break; case '#records': await handleFaqsSortingKeys(); @@ -68,6 +137,13 @@ export const handleConfiguration = async (): Promise => { break; case '#mail': await handleSMTPPasswordToggle(); + await handleMailProvider(); + break; + case '#translation': + await handleTranslationProvider(); + break; + case '#push': + await handleWebPush(); break; } @@ -76,6 +152,15 @@ export const handleConfiguration = async (): Promise => { configTabTrigger.show(); } result.innerHTML = ''; + + const filterInput = document.getElementById('pmf-configuration-tab-filter') as HTMLInputElement | null; + if (filterInput && filterInput.value.trim() !== '') { + const query = filterInput.value.trim().toLowerCase(); + const pane = document.querySelector(target) as HTMLElement | null; + if (pane) { + applyItemFilterToPane(pane, query); + } + } }); }); @@ -86,6 +171,96 @@ export const handleConfiguration = async (): Promise => { } }; +export const handleConfigurationTabFiltering = (): void => { + const filterInput = document.getElementById('pmf-configuration-tab-filter') as HTMLInputElement | null; + const tabList = document.querySelector('.pmf-configuration-tabs') as HTMLElement | null; + + if (!filterInput || !tabList) { + return; + } + + const navItems = Array.from(tabList.querySelectorAll('li.nav-item[data-config-group]')) as HTMLLIElement[]; + const groupHeaders = Array.from( + tabList.querySelectorAll('li.pmf-configuration-group[data-config-group]') + ) as HTMLLIElement[]; + + if (!navItems.length) { + return; + } + + let debounceTimer: ReturnType | null = null; + + const updateVisibility = async (): Promise => { + const query = filterInput.value.trim().toLowerCase(); + + if (query === '') { + navItems.forEach((item) => item.classList.remove('d-none')); + groupHeaders.forEach((header) => header.classList.remove('d-none')); + + TAB_TARGETS.forEach((target) => { + const pane = document.querySelector(target) as HTMLElement | null; + if (pane) { + const items = Array.from(pane.querySelectorAll('.pmf-config-item')) as HTMLElement[]; + items.forEach((item) => item.classList.remove('d-none')); + } + }); + return; + } + + await ensureAllTabsLoaded(); + + const visibleGroups = new Set(); + + navItems.forEach((item) => { + const link = item.querySelector('a.nav-link') as HTMLAnchorElement | null; + const tabTarget = link?.getAttribute('href') || ''; + const tabLabel = (item.dataset.configLabel || link?.textContent || '').trim().toLowerCase(); + + let hasMatchingItems = false; + if (tabTarget) { + const pane = document.querySelector(tabTarget) as HTMLElement | null; + if (pane) { + const matchCount = applyItemFilterToPane(pane, query); + hasMatchingItems = matchCount > 0; + } + } + + const isVisible = tabLabel.includes(query) || hasMatchingItems; + item.classList.toggle('d-none', !isVisible); + + if (isVisible) { + visibleGroups.add(item.dataset.configGroup || ''); + } + }); + + groupHeaders.forEach((groupHeader) => { + const groupName = groupHeader.dataset.configGroup || ''; + groupHeader.classList.toggle('d-none', !visibleGroups.has(groupName)); + }); + + const activeLink = tabList.querySelector('a.nav-link.active') as HTMLAnchorElement | null; + const activeItem = activeLink?.closest('li.nav-item') as HTMLLIElement | null; + + if (activeItem?.classList.contains('d-none')) { + const firstVisibleLink = tabList.querySelector('li.nav-item:not(.d-none) a.nav-link') as HTMLAnchorElement | null; + firstVisibleLink?.click(); + } + }; + + const debouncedUpdate = (): void => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + updateVisibility(); + }, 250); + }; + + filterInput.addEventListener('input', debouncedUpdate); + filterInput.addEventListener('search', debouncedUpdate); + updateVisibility(); +}; + export const handleSaveConfiguration = async (): Promise => { const saveConfigurationButton = document.getElementById('save-configuration') as HTMLButtonElement; @@ -240,6 +415,46 @@ export const handleSeoMetaTags = async (): Promise => { } }; +export const handleTranslationProvider = async (): Promise => { + const translationProviderSelectBox = document.getElementsByName( + 'edit[translation.provider]' + ) as NodeListOf; + if (translationProviderSelectBox !== null && translationProviderSelectBox[0]) { + const currentValue = (translationProviderSelectBox[0].dataset.pmfConfigurationCurrentValue as string) || 'none'; + const options = await fetchTranslationProvider(currentValue); + translationProviderSelectBox[0].insertAdjacentHTML('beforeend', options); + } +}; + +export const handleMailProvider = async (): Promise => { + const mailProviderSelectBox = document.getElementsByName('edit[mail.provider]') as NodeListOf; + if (mailProviderSelectBox !== null && mailProviderSelectBox[0]) { + const currentValue = (mailProviderSelectBox[0].dataset.pmfConfigurationCurrentValue as string) || 'smtp'; + const options = await fetchMailProvider(currentValue); + mailProviderSelectBox[0].insertAdjacentHTML('beforeend', options); + } +}; + +export const handleThemes = async (): Promise => { + const uploadForm = document.getElementById('theme-upload-form') as HTMLFormElement | null; + + if (uploadForm) { + uploadForm.addEventListener('submit', async (event: Event): Promise => { + event.preventDefault(); + + const response = (await uploadThemeArchive(new FormData(uploadForm))) as unknown as Response; + if (typeof response.success === 'string') { + pushNotification(response.success); + await handleConfigurationTab('#layout'); + await handleTemplates(); + await handleThemes(); + } else { + pushErrorNotification(response.error || 'Theme upload failed.'); + } + }); + } +}; + export const handleConfigurationTab = async (target: string): Promise => { const languageElement = document.getElementById('pmf-language') as HTMLInputElement; if (!languageElement) { diff --git a/phpmyfaq/admin/assets/src/configuration/index.ts b/phpmyfaq/admin/assets/src/configuration/index.ts index f3113ba55a..90902f5947 100644 --- a/phpmyfaq/admin/assets/src/configuration/index.ts +++ b/phpmyfaq/admin/assets/src/configuration/index.ts @@ -5,3 +5,4 @@ export * from './instance'; export * from './opensearch'; export * from './stopwords'; export * from './upgrade'; +export * from './webpush'; diff --git a/phpmyfaq/admin/assets/src/configuration/opensearch.test.ts b/phpmyfaq/admin/assets/src/configuration/opensearch.test.ts new file mode 100644 index 0000000000..eb60657e9f --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/opensearch.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleOpenSearch } from './opensearch'; +import { fetchOpenSearchAction, fetchOpenSearchStatistics, fetchOpenSearchHealthcheck } from '../api/opensearch'; + +vi.mock('../api/opensearch'); +vi.mock('../../../../assets/src/utils'); + +describe('OpenSearch Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleOpenSearch', () => { + it('should handle OpenSearch button clicks and fetch action', async () => { + document.body.innerHTML = ` + +
+
+ `; + + (fetchOpenSearchHealthcheck as Mock).mockResolvedValue({ available: true, status: 'healthy' }); + (fetchOpenSearchAction as Mock).mockResolvedValue({ success: 'Reindexing started' }); + (fetchOpenSearchStatistics as Mock).mockResolvedValue({ + index: 'test-index', + stats: { + indices: { + 'test-index': { + total: { + docs: { count: 1000 }, + store: { size_in_bytes: 1024 }, + }, + }, + }, + }, + }); + + await handleOpenSearch(); + + const button = document.querySelector('button.pmf-opensearch') as HTMLButtonElement; + button.click(); + + expect(fetchOpenSearchAction).toHaveBeenCalledWith('reindex'); + }); + + it('should handle OpenSearch statistics update when healthy', async () => { + document.body.innerHTML = ` + +
+
+ `; + + (fetchOpenSearchHealthcheck as Mock).mockResolvedValue({ available: true, status: 'healthy' }); + (fetchOpenSearchStatistics as Mock).mockResolvedValue({ + index: 'test-index', + stats: { + indices: { + 'test-index': { + total: { + docs: { count: 1000 }, + store: { size_in_bytes: 1024 }, + }, + }, + }, + }, + }); + + await handleOpenSearch(); + + // Wait for health check promise to resolve and stats to be fetched + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify health check was called + expect(fetchOpenSearchHealthcheck).toHaveBeenCalled(); + + // Stats should be populated after health check completes + await vi.waitFor( + () => { + const statsDiv = document.getElementById('pmf-opensearch-stats') as HTMLElement; + expect(statsDiv.innerHTML).toContain('Documents'); + }, + { timeout: 1000 } + ); + }); + + it('should display health check alert when OpenSearch is unavailable', async () => { + document.body.innerHTML = ` + +
+ + `; + + (fetchOpenSearchHealthcheck as Mock).mockRejectedValue(new Error('OpenSearch is unavailable')); + + await handleOpenSearch(); + + // Wait for health check promise to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + const alertDiv = document.getElementById('pmf-opensearch-healthcheck-alert') as HTMLElement; + expect(alertDiv.style.display).toBe('block'); + expect(alertDiv.querySelector('.alert-message')?.textContent).toBe('OpenSearch is unavailable'); + }); + + it('should hide health check alert when OpenSearch is available', async () => { + document.body.innerHTML = ` + +
+
+ `; + + (fetchOpenSearchHealthcheck as Mock).mockResolvedValue({ available: true, status: 'healthy' }); + (fetchOpenSearchStatistics as Mock).mockResolvedValue({ + index: 'test-index', + stats: { + indices: { + 'test-index': { + total: { + docs: { count: 1000 }, + store: { size_in_bytes: 1024 }, + }, + }, + }, + }, + }); + + await handleOpenSearch(); + + // Wait for health check promise to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + const alertDiv = document.getElementById('pmf-opensearch-healthcheck-alert') as HTMLElement; + expect(alertDiv.style.display).toBe('none'); + }); + + it('should not fetch statistics when OpenSearch is unhealthy', async () => { + document.body.innerHTML = ` + +
+
+ `; + + (fetchOpenSearchHealthcheck as Mock).mockRejectedValue(new Error('Service unavailable')); + + await handleOpenSearch(); + + // Wait for health check promise to settle + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchOpenSearchStatistics).not.toHaveBeenCalled(); + }); + + it('should show non-Error healthcheck failures as generic message', async () => { + document.body.innerHTML = ` + +
+ + `; + + (fetchOpenSearchHealthcheck as Mock).mockRejectedValue('string error'); + + await handleOpenSearch(); + + // Wait for health check promise to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + const alertDiv = document.getElementById('pmf-opensearch-healthcheck-alert') as HTMLElement; + expect(alertDiv.querySelector('.alert-message')?.textContent).toBe('OpenSearch is unavailable'); + }); + + it('should handle error response from OpenSearch action', async () => { + const { pushErrorNotification } = await import('../../../../assets/src/utils'); + + document.body.innerHTML = ` + +
+
+ `; + + (fetchOpenSearchHealthcheck as Mock).mockResolvedValue({ available: true, status: 'healthy' }); + (fetchOpenSearchAction as Mock).mockResolvedValue({ error: 'Reindex failed' }); + + await handleOpenSearch(); + + const button = document.querySelector('button.pmf-opensearch') as HTMLButtonElement; + button.click(); + + // Wait for async click handler + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Reindex failed'); + }); + + it('should handle rejected promise from OpenSearch action', async () => { + const { pushErrorNotification } = await import('../../../../assets/src/utils'); + + document.body.innerHTML = ` + +
+
+ `; + + (fetchOpenSearchHealthcheck as Mock).mockResolvedValue({ available: true, status: 'healthy' }); + (fetchOpenSearchAction as Mock).mockRejectedValue('Network error'); + + await handleOpenSearch(); + + const button = document.querySelector('button.pmf-opensearch') as HTMLButtonElement; + button.click(); + + // Wait for async click handler + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Network error'); + }); + + it('should return false from healthCheckAlert when alert element is missing', async () => { + document.body.innerHTML = ` + +
+ `; + + await handleOpenSearch(); + + // Wait for health check promise to settle + await new Promise((resolve) => setTimeout(resolve, 10)); + + // No health check should be called since the alert element is missing + expect(fetchOpenSearchHealthcheck).not.toHaveBeenCalled(); + expect(fetchOpenSearchStatistics).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/configuration/stopwords.test.ts b/phpmyfaq/admin/assets/src/configuration/stopwords.test.ts new file mode 100644 index 0000000000..931158ad50 --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/stopwords.test.ts @@ -0,0 +1,417 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleStopWords } from './stopwords'; +import { fetchByLanguage, postStopWord, removeStopWord } from '../api'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushErrorNotification: vi.fn(), + pushNotification: vi.fn(), + }; +}); + +const setupBasicDom = (): void => { + document.body.innerHTML = ` + + +
+
+ + `; +}; + +describe('StopWords Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleStopWords', () => { + it('should do nothing when language selector is missing', () => { + document.body.innerHTML = '
'; + + handleStopWords(); + + expect(fetchByLanguage).not.toHaveBeenCalled(); + }); + + it('should fetch stop words when language is changed', async () => { + setupBasicDom(); + + const stopWords = [ + { id: 1, lang: 'en', stopword: 'the' }, + { id: 2, lang: 'en', stopword: 'and' }, + ]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchByLanguage).toHaveBeenCalledWith('en'); + }); + + it('should not fetch stop words when "none" is selected', async () => { + setupBasicDom(); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'none'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchByLanguage).not.toHaveBeenCalled(); + }); + + it('should enable add button after fetching stop words', async () => { + setupBasicDom(); + + (fetchByLanguage as Mock).mockResolvedValue([]); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + const addButton = document.getElementById('pmf-stop-words-add-input') as HTMLButtonElement; + + expect(addButton.hasAttribute('disabled')).toBe(true); + + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(addButton.hasAttribute('disabled')).toBe(false); + }); + + it('should populate stop words content after fetching', async () => { + setupBasicDom(); + + const stopWords = [ + { id: 1, lang: 'en', stopword: 'the' }, + { id: 2, lang: 'en', stopword: 'and' }, + ]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const content = document.getElementById('pmf-stopwords-content') as HTMLElement; + expect(content.innerHTML).toContain('table'); + expect(content.innerHTML).toContain('stopword_1_en'); + expect(content.innerHTML).toContain('stopword_2_en'); + }); + + it('should show loading indicator while fetching and hide it after', async () => { + setupBasicDom(); + + (fetchByLanguage as Mock).mockResolvedValue([]); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const loadingIndicator = document.getElementById('pmf-stop-words-loading-indicator') as HTMLElement; + expect(loadingIndicator.innerHTML).toBe(''); + }); + + it('should add a new empty stop word input when add button is clicked', async () => { + setupBasicDom(); + + (fetchByLanguage as Mock).mockResolvedValue([]); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const addButton = document.getElementById('pmf-stop-words-add-input') as HTMLButtonElement; + addButton.click(); + + const content = document.getElementById('pmf-stopwords-content') as HTMLElement; + expect(content.innerHTML).toContain('stopword_-1_en'); + }); + + it('should save stop word on blur when value changes', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + (postStopWord as Mock).mockResolvedValue({ success: true }); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + + // Focus to save old value + input.dispatchEvent(new Event('focus')); + expect(input.getAttribute('data-old-value')).toBe('the'); + + // Change value and blur to trigger save + input.value = 'updated'; + input.dispatchEvent(new Event('blur')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(postStopWord).toHaveBeenCalledWith('test-csrf-token', 'updated', 1, 'en'); + }); + + it('should show success styling after saving stop word', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + (postStopWord as Mock).mockResolvedValue({ success: true }); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + input.dispatchEvent(new Event('focus')); + input.value = 'updated'; + input.dispatchEvent(new Event('blur')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(input.style.borderColor).toBe('rgb(25, 135, 84)'); + }); + + it('should show error alert when saving stop word fails', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + (postStopWord as Mock).mockRejectedValue(new Error('Save failed')); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + input.dispatchEvent(new Event('focus')); + input.value = 'updated'; + input.dispatchEvent(new Event('blur')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const errorAlert = document.querySelector('.alert-danger') as HTMLElement; + expect(errorAlert).not.toBeNull(); + expect(errorAlert.innerText).toBe('Save failed'); + }); + + it('should not save when value has not changed', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + input.dispatchEvent(new Event('focus')); + // Do not change the value + input.dispatchEvent(new Event('blur')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(postStopWord).not.toHaveBeenCalled(); + }); + + it('should remove empty new input on blur when value is unchanged', async () => { + setupBasicDom(); + + (fetchByLanguage as Mock).mockResolvedValue([]); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Click the add button to create a new empty input (id=-1) + const addButton = document.getElementById('pmf-stop-words-add-input') as HTMLButtonElement; + addButton.click(); + + const input = document.getElementById('stopword_-1_en') as HTMLInputElement; + expect(input).not.toBeNull(); + + // Focus then blur without changing value + input.dispatchEvent(new Event('focus')); + input.dispatchEvent(new Event('blur')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Element should be removed + expect(document.getElementById('stopword_-1_en')).toBeNull(); + }); + + it('should delete stop word on Enter when input is empty', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + (removeStopWord as Mock).mockResolvedValue({ success: true }); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 })); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(removeStopWord).toHaveBeenCalledWith('test-csrf-token', 1, 'en'); + }); + + it('should blur input on Enter when input has a value', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + (postStopWord as Mock).mockResolvedValue({ success: true }); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + const blurSpy = vi.spyOn(input, 'blur'); + + input.dispatchEvent(new Event('focus')); + input.value = 'updated'; + input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 })); + + expect(blurSpy).toHaveBeenCalled(); + }); + + it('should show error alert when deleting stop word fails', async () => { + setupBasicDom(); + + const stopWords = [{ id: 1, lang: 'en', stopword: 'the' }]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + (removeStopWord as Mock).mockRejectedValue(new Error('Delete failed')); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const input = document.getElementById('stopword_1_en') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 })); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const errorAlert = document.querySelector('.alert-danger') as HTMLElement; + expect(errorAlert).not.toBeNull(); + expect(errorAlert.innerText).toBe('Delete failed'); + }); + + it('should handle fetch error gracefully', async () => { + setupBasicDom(); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (fetchByLanguage as Mock).mockRejectedValue(new Error('Network error')); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(consoleSpy).toHaveBeenCalledWith('Error fetching stop words:', 'Network error'); + consoleSpy.mockRestore(); + }); + + it('should build table with correct number of rows based on maxCols', async () => { + setupBasicDom(); + + // 5 stop words with maxCols=4 should create 2 rows + const stopWords = [ + { id: 1, lang: 'en', stopword: 'the' }, + { id: 2, lang: 'en', stopword: 'and' }, + { id: 3, lang: 'en', stopword: 'but' }, + { id: 4, lang: 'en', stopword: 'for' }, + { id: 5, lang: 'en', stopword: 'not' }, + ]; + (fetchByLanguage as Mock).mockResolvedValue(stopWords); + + handleStopWords(); + + const selector = document.getElementById('pmf-stop-words-language-selector') as HTMLSelectElement; + selector.value = 'en'; + selector.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const content = document.getElementById('pmf-stopwords-content') as HTMLElement; + const rows = content.querySelectorAll('tr'); + expect(rows.length).toBe(2); + + // The first row should have 4 inputs, the second row should have 1 + expect(rows[0].querySelectorAll('td').length).toBe(4); + expect(rows[1].querySelectorAll('td').length).toBe(1); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/configuration/webpush.test.ts b/phpmyfaq/admin/assets/src/configuration/webpush.test.ts new file mode 100644 index 0000000000..fc692e4aa4 --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/webpush.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleWebPush } from './webpush'; +import { fetchGenerateVapidKeys } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils'); + +const setupBasicDom = (options?: { publicKey?: string; privateKey?: string; hasParent?: boolean }): void => { + const { publicKey = '', privateKey = '', hasParent = true } = options ?? {}; + + const availableFields = JSON.stringify([ + 'main.titleFAQ', + 'push.vapidPublicKey', + 'push.vapidPrivateKey', + 'records.numberOfRecordsPerPage', + ]); + + if (hasParent) { + document.body.innerHTML = ` +
+ +
+
+ +
+ + + `; + } else { + document.body.innerHTML = ` + + + + + `; + } +}; + +describe('WebPush Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleWebPush', () => { + it('should return early when public key input is missing', async () => { + document.body.innerHTML = '
'; + + await handleWebPush(); + + expect(document.getElementById('pmf-generate-vapid-keys')).toBeNull(); + }); + + it('should remove name attributes from VAPID key inputs', async () => { + setupBasicDom(); + + await handleWebPush(); + + const publicKeyInput = document.getElementById('edit[push.vapidPublicKey]') as HTMLInputElement; + const privateKeyInput = document.getElementById('edit[push.vapidPrivateKey]') as HTMLInputElement; + + expect(publicKeyInput.hasAttribute('name')).toBe(false); + expect(privateKeyInput.hasAttribute('name')).toBe(false); + }); + + it('should filter VAPID fields from availableFields input', async () => { + setupBasicDom(); + + await handleWebPush(); + + const availableFieldsInput = document.querySelector('input[name="availableFields"]'); + const fields = JSON.parse(availableFieldsInput?.value ?? '[]'); + + expect(fields).not.toContain('push.vapidPublicKey'); + expect(fields).not.toContain('push.vapidPrivateKey'); + expect(fields).toContain('main.titleFAQ'); + expect(fields).toContain('records.numberOfRecordsPerPage'); + }); + + it('should mask private key with bullet characters when it has a value', async () => { + setupBasicDom({ privateKey: 'secret-private-key' }); + + await handleWebPush(); + + const privateKeyInput = document.getElementById('edit[push.vapidPrivateKey]') as HTMLInputElement; + expect(privateKeyInput.value).toBe('\u2022'.repeat(20)); + }); + + it('should not mask private key when it is empty', async () => { + setupBasicDom({ privateKey: '' }); + + await handleWebPush(); + + const privateKeyInput = document.getElementById('edit[push.vapidPrivateKey]') as HTMLInputElement; + expect(privateKeyInput.value).toBe(''); + }); + + it('should add generate VAPID keys button', async () => { + setupBasicDom(); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + expect(button).not.toBeNull(); + expect(button.type).toBe('button'); + expect(button.className).toBe('btn btn-outline-primary mt-2'); + expect(button.innerHTML).toContain('Generate VAPID Keys'); + }); + + it('should not add the button multiple times', async () => { + setupBasicDom(); + + await handleWebPush(); + await handleWebPush(); + + const buttons = document.querySelectorAll('#pmf-generate-vapid-keys'); + expect(buttons.length).toBe(1); + }); + + it('should generate VAPID keys on button click and show success', async () => { + setupBasicDom(); + + (fetchGenerateVapidKeys as Mock).mockResolvedValue({ + success: true, + publicKey: 'generated-public-key', + }); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchGenerateVapidKeys).toHaveBeenCalledWith('test-csrf-token'); + + const publicKeyInput = document.getElementById('edit[push.vapidPublicKey]') as HTMLInputElement; + expect(publicKeyInput.value).toBe('generated-public-key'); + + expect(pushNotification).toHaveBeenCalledWith('VAPID keys have been generated successfully.'); + }); + + it('should mask private key after successful generation', async () => { + setupBasicDom(); + + (fetchGenerateVapidKeys as Mock).mockResolvedValue({ + success: true, + publicKey: 'generated-public-key', + }); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const privateKeyInput = document.getElementById('edit[push.vapidPrivateKey]') as HTMLInputElement; + expect(privateKeyInput.value).toBe('\u2022'.repeat(20)); + }); + + it('should show error notification on API error response', async () => { + setupBasicDom(); + + (fetchGenerateVapidKeys as Mock).mockResolvedValue({ + success: false, + publicKey: '', + error: 'Server error occurred', + }); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Server error occurred'); + }); + + it('should show default error message when API returns no error string', async () => { + setupBasicDom(); + + (fetchGenerateVapidKeys as Mock).mockResolvedValue({ + success: false, + publicKey: '', + }); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Failed to generate VAPID keys.'); + }); + + it('should show error notification when fetch rejects', async () => { + setupBasicDom(); + + (fetchGenerateVapidKeys as Mock).mockRejectedValue(new Error('Network error')); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Failed to generate VAPID keys.'); + }); + + it('should disable button during generation and re-enable after', async () => { + setupBasicDom(); + + let resolvePromise: (value: unknown) => void = () => {}; + (fetchGenerateVapidKeys as Mock).mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }) + ); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + // Button should be disabled and show spinner + expect(button.disabled).toBe(true); + expect(button.innerHTML).toContain('spinner-border'); + expect(button.innerHTML).toContain('Generating...'); + + // Resolve the promise + resolvePromise({ success: true, publicKey: 'key' }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Button should be re-enabled with original content + expect(button.disabled).toBe(false); + expect(button.innerHTML).toContain('Generate VAPID Keys'); + }); + + it('should re-enable button after failed generation', async () => { + setupBasicDom(); + + (fetchGenerateVapidKeys as Mock).mockRejectedValue(new Error('fail')); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(button.disabled).toBe(false); + expect(button.innerHTML).toContain('Generate VAPID Keys'); + }); + + it('should return early when public key input has no parent element', async () => { + // Create input without parent by using a document fragment trick + document.body.innerHTML = ` + + + `; + + // Remove the parent element reference by moving the input to a fragment + const input = document.getElementById('edit[push.vapidPublicKey]') as HTMLInputElement; + const fragment = document.createDocumentFragment(); + fragment.appendChild(input); + + await handleWebPush(); + + // Button should not be created since parentElement is null in a fragment + expect(document.getElementById('pmf-generate-vapid-keys')).toBeNull(); + }); + + it('should handle missing availableFields input gracefully', async () => { + document.body.innerHTML = ` +
+ +
+ + `; + + // Should not throw + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + expect(button).not.toBeNull(); + }); + + it('should handle invalid JSON in availableFields gracefully', async () => { + document.body.innerHTML = ` +
+ +
+ + + `; + + // Should not throw due to catch block + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + expect(button).not.toBeNull(); + }); + + it('should handle missing csrf token input', async () => { + document.body.innerHTML = ` +
+ +
+ `; + + (fetchGenerateVapidKeys as Mock).mockResolvedValue({ + success: true, + publicKey: 'key', + }); + + await handleWebPush(); + + const button = document.getElementById('pmf-generate-vapid-keys') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchGenerateVapidKeys).toHaveBeenCalledWith(''); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/configuration/webpush.ts b/phpmyfaq/admin/assets/src/configuration/webpush.ts new file mode 100644 index 0000000000..040f5be4ff --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/webpush.ts @@ -0,0 +1,97 @@ +/** + * Admin Web Push configuration + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-04 + */ + +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; +import { fetchGenerateVapidKeys } from '../api'; + +export const handleWebPush = async (): Promise => { + const publicKeyInput = document.getElementById('edit[push.vapidPublicKey]') as HTMLInputElement | null; + const privateKeyInput = document.getElementById('edit[push.vapidPrivateKey]') as HTMLInputElement | null; + + if (!publicKeyInput) { + return; + } + + // Remove name attributes so VAPID keys are excluded from the config form submission. + // They are managed separately via the generate-vapid-keys API endpoint. + publicKeyInput.removeAttribute('name'); + privateKeyInput?.removeAttribute('name'); + + // Also remove VAPID key fields from the availableFields hidden input + // to prevent them from being processed during form save. + const availableFieldsInput = document.querySelector('input[name="availableFields"]'); + if (availableFieldsInput) { + try { + const fields: string[] = JSON.parse(availableFieldsInput.value); + const filtered = fields.filter( + (field: string) => field !== 'push.vapidPublicKey' && field !== 'push.vapidPrivateKey' + ); + availableFieldsInput.value = JSON.stringify(filtered); + } catch (_e) { + // Ignore parse errors + } + } + + // Mask the private key for display + if (privateKeyInput && privateKeyInput.value !== '') { + privateKeyInput.value = '\u2022'.repeat(20); + } + + const parentDiv = publicKeyInput.parentElement; + if (!parentDiv) { + return; + } + + // Avoid adding the button multiple times + if (parentDiv.querySelector('#pmf-generate-vapid-keys')) { + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.id = 'pmf-generate-vapid-keys'; + button.className = 'btn btn-outline-primary mt-2'; + button.innerHTML = ' Generate VAPID Keys'; + parentDiv.appendChild(button); + + button.addEventListener('click', async (event: Event): Promise => { + event.preventDefault(); + button.disabled = true; + button.innerHTML = + ' Generating...'; + + try { + const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value ?? ''; + const response = await fetchGenerateVapidKeys(csrfToken); + + if (response.success) { + publicKeyInput.value = response.publicKey; + + if (privateKeyInput) { + privateKeyInput.value = '\u2022'.repeat(20); + } + + pushNotification('VAPID keys have been generated successfully.'); + } else { + pushErrorNotification(response.error ?? 'Failed to generate VAPID keys.'); + } + } catch (_error) { + pushErrorNotification('Failed to generate VAPID keys.'); + } finally { + button.disabled = false; + button.innerHTML = ' Generate VAPID Keys'; + } + }); +}; diff --git a/phpmyfaq/admin/assets/src/content/attachment-upload.test.ts b/phpmyfaq/admin/assets/src/content/attachment-upload.test.ts index 11b6667320..bf1003acd5 100644 --- a/phpmyfaq/admin/assets/src/content/attachment-upload.test.ts +++ b/phpmyfaq/admin/assets/src/content/attachment-upload.test.ts @@ -507,7 +507,7 @@ describe('handleAttachmentUploads', () => { 'a', expect.objectContaining({ className: 'me-2', - href: '../index.php?action=attachment&id=789', + href: '../attachment/789', innerText: 'document.pdf', }) ); diff --git a/phpmyfaq/admin/assets/src/content/attachment-upload.ts b/phpmyfaq/admin/assets/src/content/attachment-upload.ts index 2b25c47a75..e9191b9e7d 100644 --- a/phpmyfaq/admin/assets/src/content/attachment-upload.ts +++ b/phpmyfaq/admin/assets/src/content/attachment-upload.ts @@ -86,7 +86,7 @@ export const handleAttachmentUploads = (): void => { addElement('li', {}, [ addElement('a', { className: 'me-2', - href: `../index.php?action=attachment&id=${attachment.attachmentId}`, + href: `../attachment/${attachment.attachmentId}`, innerText: attachment.fileName, }), addElement( diff --git a/phpmyfaq/admin/assets/src/content/attachments.test.ts b/phpmyfaq/admin/assets/src/content/attachments.test.ts new file mode 100644 index 0000000000..2ada7623a5 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/attachments.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleDeleteAttachments, handleRefreshAttachments } from './attachments'; +import { deleteAttachments, refreshAttachments } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils'); + +describe('Attachment Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleDeleteAttachments', () => { + it('should do nothing when no delete buttons exist', () => { + document.body.innerHTML = '
'; + + handleDeleteAttachments(); + + expect(deleteAttachments).not.toHaveBeenCalled(); + }); + + it('should call deleteAttachments with correct parameters on button click', async () => { + document.body.innerHTML = ` + +
+ `; + + (deleteAttachments as Mock).mockResolvedValue({ success: 'Attachment deleted' }); + + handleDeleteAttachments(); + + const button = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteAttachments).toHaveBeenCalledWith('42', 'csrf-token-123'); + }); + + it('should show success notification and fade out row on successful delete', async () => { + document.body.innerHTML = ` + +
Attachment row
+ `; + + (deleteAttachments as Mock).mockResolvedValue({ success: 'Attachment deleted' }); + + handleDeleteAttachments(); + + const button = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushNotification).toHaveBeenCalledWith('Attachment deleted'); + + const row = document.getElementById('attachment_42') as HTMLElement; + expect(row.style.opacity).toBe('0'); + }); + + it('should remove row element on transitionend after delete', async () => { + document.body.innerHTML = ` + +
Attachment row
+ `; + + (deleteAttachments as Mock).mockResolvedValue({ success: 'Attachment deleted' }); + + handleDeleteAttachments(); + + const button = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const row = document.getElementById('attachment_42') as HTMLElement; + row.dispatchEvent(new Event('transitionend')); + + expect(document.getElementById('attachment_42')).toBeNull(); + }); + + it('should show error notification on error response', async () => { + document.body.innerHTML = ` + +
Attachment row
+ `; + + (deleteAttachments as Mock).mockResolvedValue({ error: 'Delete failed' }); + + handleDeleteAttachments(); + + const button = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Delete failed'); + }); + + it('should not call deleteAttachments when attachment-id is missing', async () => { + document.body.innerHTML = ` + + `; + + handleDeleteAttachments(); + + const button = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteAttachments).not.toHaveBeenCalled(); + }); + + it('should not call deleteAttachments when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleDeleteAttachments(); + + const button = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteAttachments).not.toHaveBeenCalled(); + }); + + it('should handle multiple delete buttons independently', async () => { + document.body.innerHTML = ` + + +
Row 1
+
Row 2
+ `; + + (deleteAttachments as Mock).mockResolvedValue({ success: 'Deleted' }); + + handleDeleteAttachments(); + + const buttons = document.querySelectorAll('.btn-delete-attachment'); + (buttons[1] as HTMLButtonElement).click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteAttachments).toHaveBeenCalledWith('2', 'csrf-2'); + }); + + it('should replace buttons with clones to remove old event listeners', () => { + document.body.innerHTML = ` + + `; + + const originalButton = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + const addEventListenerSpy = vi.spyOn(originalButton, 'addEventListener'); + + handleDeleteAttachments(); + + // The original button should NOT have a new listener (it was replaced by a clone) + expect(addEventListenerSpy).not.toHaveBeenCalled(); + + // A new button should exist in the DOM + const newButton = document.querySelector('.btn-delete-attachment') as HTMLButtonElement; + expect(newButton).not.toBeNull(); + }); + }); + + describe('handleRefreshAttachments', () => { + it('should do nothing when no refresh buttons exist', () => { + document.body.innerHTML = '
'; + + handleRefreshAttachments(); + + expect(refreshAttachments).not.toHaveBeenCalled(); + }); + + it('should call refreshAttachments with correct parameters on button click', async () => { + document.body.innerHTML = ` + +
Attachment row
+ `; + + (refreshAttachments as Mock).mockResolvedValue({ success: 'Attachment refreshed' }); + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(refreshAttachments).toHaveBeenCalledWith('42', 'csrf-token-123'); + }); + + it('should show success notification on successful refresh', async () => { + document.body.innerHTML = ` + + `; + + (refreshAttachments as Mock).mockResolvedValue({ success: 'Attachment refreshed' }); + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushNotification).toHaveBeenCalledWith('Attachment refreshed'); + }); + + it('should fade out and remove row when response includes delete flag', async () => { + document.body.innerHTML = ` + +
Attachment row
+ `; + + (refreshAttachments as Mock).mockResolvedValue({ success: 'Refreshed', delete: true }); + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const row = document.getElementById('attachment_42') as HTMLElement; + expect(row.style.opacity).toBe('0'); + + row.dispatchEvent(new Event('transitionend')); + expect(document.getElementById('attachment_42')).toBeNull(); + }); + + it('should not remove row when response does not include delete flag', async () => { + document.body.innerHTML = ` + +
Attachment row
+ `; + + (refreshAttachments as Mock).mockResolvedValue({ success: 'Refreshed' }); + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const row = document.getElementById('attachment_42') as HTMLElement; + expect(row.style.opacity).not.toBe('0'); + }); + + it('should show error notification on error response', async () => { + document.body.innerHTML = ` + + `; + + (refreshAttachments as Mock).mockResolvedValue({ error: 'Refresh failed' }); + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Refresh failed'); + }); + + it('should not call refreshAttachments when attachment-id is missing', async () => { + document.body.innerHTML = ` + + `; + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(refreshAttachments).not.toHaveBeenCalled(); + }); + + it('should not call refreshAttachments when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleRefreshAttachments(); + + const button = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(refreshAttachments).not.toHaveBeenCalled(); + }); + + it('should handle multiple refresh buttons independently', async () => { + document.body.innerHTML = ` + + + `; + + (refreshAttachments as Mock).mockResolvedValue({ success: 'Refreshed' }); + + handleRefreshAttachments(); + + const buttons = document.querySelectorAll('.btn-refresh-attachment'); + (buttons[0] as HTMLButtonElement).click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(refreshAttachments).toHaveBeenCalledWith('1', 'csrf-1'); + }); + + it('should replace buttons with clones to remove old event listeners', () => { + document.body.innerHTML = ` + + `; + + const originalButton = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + const addEventListenerSpy = vi.spyOn(originalButton, 'addEventListener'); + + handleRefreshAttachments(); + + expect(addEventListenerSpy).not.toHaveBeenCalled(); + + const newButton = document.querySelector('.btn-refresh-attachment') as HTMLButtonElement; + expect(newButton).not.toBeNull(); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/attachments.ts b/phpmyfaq/admin/assets/src/content/attachments.ts index f74f59f510..a90b9f0480 100644 --- a/phpmyfaq/admin/assets/src/content/attachments.ts +++ b/phpmyfaq/admin/assets/src/content/attachments.ts @@ -32,7 +32,7 @@ export const handleDeleteAttachments = (): void => { const csrf = newButton.getAttribute('data-csrf'); if (attachmentId && csrf) { - const response = await deleteAttachments(attachmentId, csrf); + const response = (await deleteAttachments(attachmentId, csrf)) as unknown as Response; if (response.success) { pushNotification(response.success); diff --git a/phpmyfaq/admin/assets/src/content/category.test.ts b/phpmyfaq/admin/assets/src/content/category.test.ts new file mode 100644 index 0000000000..8ef4a6aa6e --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/category.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleCategories, handleCategoryDelete, handleResetCategoryImage, handleCategoryTranslate } from './category'; +import { deleteCategory } from '../api'; +import { pushNotification } from '../../../../assets/src/utils'; + +vi.mock('sortablejs', () => ({ + default: vi.fn().mockImplementation(() => ({})), +})); +vi.mock('bootstrap', () => ({ + Modal: class { + show = vi.fn(); + hide = vi.fn(); + }, +})); +vi.mock('../api'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); +vi.mock('../translation/translator', () => { + return { + Translator: vi.fn(), + }; +}); + +describe('Category Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleCategories', () => { + it('should not throw when no .nested-sortable elements exist', () => { + document.body.innerHTML = '
'; + + expect(() => handleCategories()).not.toThrow(); + }); + }); + + describe('handleCategoryDelete', () => { + it('should do nothing when modal element is missing', async () => { + document.body.innerHTML = ` + + `; + + await handleCategoryDelete(); + + expect(deleteCategory).not.toHaveBeenCalled(); + }); + + it('should do nothing when delete buttons are missing', async () => { + document.body.innerHTML = ` +
+ + `; + + await handleCategoryDelete(); + + expect(deleteCategory).not.toHaveBeenCalled(); + }); + + it('should call deleteCategory, remove element, and show notification on confirm', async () => { + document.body.innerHTML = ` +
+ + +
Category row
+ + `; + + (deleteCategory as Mock).mockResolvedValue({ success: 'Category deleted successfully' }); + + await handleCategoryDelete(); + + // Click the delete button to open the modal and set the category info + const deleteButton = document.querySelector('[name="pmf-category-delete-button"]') as HTMLButtonElement; + deleteButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Click the confirm button to trigger deletion + const confirmButton = document.getElementById('confirmDeleteButton') as HTMLButtonElement; + confirmButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteCategory).toHaveBeenCalledWith('5', 'en', 'csrf-token-abc'); + expect(pushNotification).toHaveBeenCalledWith('Category deleted successfully'); + expect(document.getElementById('pmf-category-5')).toBeNull(); + }); + + it('should not call deleteCategory when categoryId and language are empty', async () => { + document.body.innerHTML = ` +
+ + + + `; + + await handleCategoryDelete(); + + // Click the delete button + const deleteButton = document.querySelector('[name="pmf-category-delete-button"]') as HTMLButtonElement; + deleteButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Click confirm + const confirmButton = document.getElementById('confirmDeleteButton') as HTMLButtonElement; + confirmButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteCategory).not.toHaveBeenCalled(); + }); + }); + + describe('handleResetCategoryImage', () => { + it('should not throw when reset button is missing', () => { + document.body.innerHTML = '
'; + + expect(() => handleResetCategoryImage()).not.toThrow(); + }); + + it('should clear image input values and label when reset button is clicked', () => { + document.body.innerHTML = ` + + + + + `; + + handleResetCategoryImage(); + + const resetButton = document.getElementById('button-reset-category-image') as HTMLButtonElement; + resetButton.click(); + + const existingImage = document.getElementById('pmf-category-existing-image') as HTMLInputElement; + const imageUpload = document.getElementById('pmf-category-image-upload') as HTMLInputElement; + const imageLabel = document.getElementById('pmf-category-image-label') as HTMLLabelElement; + + expect(existingImage.value).toBe(''); + expect(imageUpload.value).toBe(''); + expect(imageLabel.innerHTML).toBe(''); + }); + }); + + describe('handleCategoryTranslate', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + beforeEach(() => { + consoleErrorSpy.mockClear(); + }); + + it('should not throw when elements are missing', () => { + document.body.innerHTML = '
'; + + expect(() => handleCategoryTranslate()).not.toThrow(); + }); + + it('should enable translate button when different source and target languages are selected', () => { + document.body.innerHTML = ` + + + + `; + + handleCategoryTranslate(); + + const translateButton = document.getElementById('btn-translate-category-ai') as HTMLButtonElement; + expect(translateButton.disabled).toBe(true); + + const langSelect = document.getElementById('catlang') as HTMLSelectElement; + langSelect.value = 'de'; + langSelect.dispatchEvent(new Event('change')); + + expect(translateButton.disabled).toBe(false); + }); + + it('should disable translate button when same language is selected', () => { + document.body.innerHTML = ` + + + + `; + + handleCategoryTranslate(); + + const translateButton = document.getElementById('btn-translate-category-ai') as HTMLButtonElement; + const langSelect = document.getElementById('catlang') as HTMLSelectElement; + + // First select a different language to enable + langSelect.value = 'de'; + langSelect.dispatchEvent(new Event('change')); + expect(translateButton.disabled).toBe(false); + + // Then select the same language as source to disable + langSelect.value = 'en'; + langSelect.dispatchEvent(new Event('change')); + expect(translateButton.disabled).toBe(true); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/category.ts b/phpmyfaq/admin/assets/src/content/category.ts index 15a8e58eea..08c395bcae 100644 --- a/phpmyfaq/admin/assets/src/content/category.ts +++ b/phpmyfaq/admin/assets/src/content/category.ts @@ -18,6 +18,7 @@ import { Modal } from 'bootstrap'; import { deleteCategory, setCategoryTree } from '../api'; import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; import { Response } from '../interfaces'; +import { Translator } from '../translation/translator'; const nestedQuery = '.nested-sortable'; const identifier = 'pmfCatid'; @@ -46,7 +47,7 @@ export const handleCategories = (): void => { }); }, onEnd: async (event: SortableEvent): Promise => { - // Remove class from all drop zones when drag ends + // Remove the class from all drop zones when drag ends const allSortables = document.querySelectorAll(nestedQuery); allSortables.forEach((sortable: HTMLElement): void => { sortable.classList.remove('sortable-drag-active'); @@ -133,3 +134,52 @@ export const handleResetCategoryImage = (): void => { }); } }; + +export const handleCategoryTranslate = (): void => { + const translateButton = document.getElementById('btn-translate-category-ai') as HTMLButtonElement | null; + const langSelect = document.getElementById('catlang') as HTMLSelectElement | null; + const originalLangInput = document.getElementById('originalCategoryLang') as HTMLInputElement | null; + + if (!translateButton || !langSelect || !originalLangInput) { + return; + } + + // Initialize translator when the target language is selected + langSelect.addEventListener('change', () => { + const sourceLang = originalLangInput.value; + const targetLang = langSelect.value; + + if (sourceLang && targetLang && sourceLang !== targetLang) { + // Enable the translation button + translateButton.disabled = false; + + // Initialize the Translator + try { + new Translator({ + buttonSelector: '#btn-translate-category-ai', + contentType: 'category', + sourceLang: sourceLang, + targetLang: targetLang, + fieldMapping: { + name: '#name', + description: '#description', + }, + onTranslationSuccess: () => { + pushNotification('Translation completed successfully'); + }, + onTranslationError: (error) => { + pushErrorNotification(`Translation failed: ${error}`); + }, + }); + } catch (error) { + console.error('Failed to initialize translator:', error); + } + } else { + // Disable the translation button if same language or no target language + translateButton.disabled = true; + } + }); + + // Initially disable the button + translateButton.disabled = true; +}; diff --git a/phpmyfaq/admin/assets/src/content/comment.test.ts b/phpmyfaq/admin/assets/src/content/comment.test.ts new file mode 100644 index 0000000000..68fc366ad8 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/comment.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleDeleteComments } from './comment'; + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual }; +}); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('handleDeleteComments', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('when buttons do not exist', () => { + it('should do nothing when neither button exists', () => { + document.body.innerHTML = '
'; + + handleDeleteComments(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should not throw when buttons are absent', () => { + document.body.innerHTML = '
'; + + expect(() => handleDeleteComments()).not.toThrow(); + }); + }); + + describe('FAQ comment deletion', () => { + const setupFaqDom = () => { + document.body.innerHTML = ` +
+
+ + + + + + + + + + + +
+ + Comment 1
+ + Comment 2
+
+ + `; + }; + + it('should remove checked rows on successful deletion', async () => { + setupFaqDom(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const checkedInputs = document.querySelectorAll('tr td input:checked'); + expect(checkedInputs.length).toBe(0); + + // The unchecked row should still exist + const remainingRows = document.querySelectorAll('tr'); + expect(remainingRows.length).toBe(1); + }); + + it('should show alert on error response from server', async () => { + setupFaqDom(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: false, error: 'Deletion failed' }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const responseMessage = document.getElementById('returnMessage') as HTMLElement; + const alertDiv = responseMessage.querySelector('.alert.alert-danger') as HTMLElement; + expect(alertDiv).not.toBeNull(); + expect(alertDiv.innerText).toBe('Deletion failed'); + }); + + it('should show alert on network error (non-ok response)', async () => { + setupFaqDom(); + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const responseMessage = document.getElementById('returnMessage') as HTMLElement; + const alertDiv = responseMessage.querySelector('.alert.alert-danger') as HTMLElement; + expect(alertDiv).not.toBeNull(); + expect(alertDiv.innerText).toBe('Network response was not ok.'); + }); + + it('should show alert when fetch rejects', async () => { + setupFaqDom(); + + mockFetch.mockRejectedValue(new Error('Failed to fetch')); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const responseMessage = document.getElementById('returnMessage') as HTMLElement; + const alertDiv = responseMessage.querySelector('.alert.alert-danger') as HTMLElement; + expect(alertDiv).not.toBeNull(); + expect(alertDiv.innerText).toBe('Failed to fetch'); + }); + + it('should call fetch with correct method, headers, and body', async () => { + setupFaqDom(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('api/content/comments'); + expect(options.method).toBe('DELETE'); + expect(options.headers).toEqual({ + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }); + + const body = JSON.parse(options.body); + expect(body.type).toBe('faq'); + expect(body.data).toBeDefined(); + }); + }); + + describe('News comment deletion', () => { + const setupNewsDom = () => { + document.body.innerHTML = ` +
+
+ + + + + + + + + + + +
+ + News Comment 1
+ + News Comment 2
+
+ + `; + }; + + it('should remove checked rows on successful deletion', async () => { + setupNewsDom(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-news-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const checkedInputs = document.querySelectorAll('tr td input:checked'); + expect(checkedInputs.length).toBe(0); + + // Both rows were checked, so both should be removed + const remainingRows = document.querySelectorAll('tr'); + expect(remainingRows.length).toBe(0); + }); + + it('should call fetch with type "news" in the body', async () => { + setupNewsDom(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-news-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.type).toBe('news'); + expect(body.data).toBeDefined(); + }); + + it('should show alert on error response from server', async () => { + setupNewsDom(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: false, error: 'News deletion failed' }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-news-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const responseMessage = document.getElementById('returnMessage') as HTMLElement; + const alertDiv = responseMessage.querySelector('.alert.alert-danger') as HTMLElement; + expect(alertDiv).not.toBeNull(); + expect(alertDiv.innerText).toBe('News deletion failed'); + }); + + it('should show alert on network error', async () => { + setupNewsDom(); + + mockFetch.mockRejectedValue(new Error('Network failure')); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-news-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const responseMessage = document.getElementById('returnMessage') as HTMLElement; + const alertDiv = responseMessage.querySelector('.alert.alert-danger') as HTMLElement; + expect(alertDiv).not.toBeNull(); + expect(alertDiv.innerText).toBe('Network failure'); + }); + }); + + describe('fetch call details', () => { + it('should serialize form data and include it in the request body', async () => { + document.body.innerHTML = ` +
+
+ + +
+ + `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.type).toBe('faq'); + expect(body.data).toBeDefined(); + expect(body.data['faq_comments[]']).toBeDefined(); + }); + + it('should use DELETE method for the fetch call', async () => { + document.body.innerHTML = ` +
+
+ +
+ + `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-faq-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('DELETE'); + }); + + it('should include correct Accept and Content-Type headers', async () => { + document.body.innerHTML = ` +
+
+ +
+ + `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + handleDeleteComments(); + + const button = document.getElementById('pmf-button-delete-news-comments') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers).toEqual({ + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/csvimport.test.ts b/phpmyfaq/admin/assets/src/content/csvimport.test.ts new file mode 100644 index 0000000000..a827f7507a --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/csvimport.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleUploadCSVForm } from './csvimport'; +import { pushNotification, pushErrorNotification } from '../../../../assets/src/utils'; + +vi.mock('../../../../assets/src/utils'); + +const setupBasicDom = (): void => { + document.body.innerHTML = ` +
+ + +
+ `; +}; + +describe('CSV Import Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleUploadCSVForm', () => { + it('should do nothing when submit button is missing', async () => { + document.body.innerHTML = '
'; + + await handleUploadCSVForm(); + + // No event listeners should be attached + expect(document.querySelectorAll('button').length).toBe(0); + }); + + it('should show error notification when no file is selected', async () => { + setupBasicDom(); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('No file selected.'); + }); + + it('should upload file and show success notification on OK response', async () => { + setupBasicDom(); + + const mockFile = new File(['question,answer\nQ1,A1'], 'faqs.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + const mockFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: 'Import successful' }), + } as Response); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockFetch).toHaveBeenCalledWith('./api/faq/import', expect.objectContaining({ method: 'POST' })); + expect(pushNotification).toHaveBeenCalledWith('Import successful'); + }); + + it('should clear file input after successful upload', async () => { + setupBasicDom(); + + const mockFile = new File(['data'], 'faqs.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: 'Done' }), + } as Response); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fileInput.value).toBe(''); + }); + + it('should show error notification on 400 response', async () => { + setupBasicDom(); + + const mockFile = new File(['bad data'], 'faqs.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: 'Invalid CSV format' }), + } as Response); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Invalid CSV format'); + }); + + it('should show error notification on other error responses', async () => { + setupBasicDom(); + + const mockFile = new File(['data'], 'faqs.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal server error' }), + } as Response); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(consoleSpy).toHaveBeenCalled(); + expect(pushErrorNotification).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('should handle import errors with messages array', async () => { + setupBasicDom(); + + const mockFile = new File(['data'], 'faqs.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue({ + storedAll: false, + messages: ['Error on row 1', 'Error on row 2'], + }); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Error on row 1'); + expect(pushErrorNotification).toHaveBeenCalledWith('Error on row 2'); + }); + + it('should handle generic error during import', async () => { + setupBasicDom(); + + const mockFile = new File(['data'], 'faqs.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network failure')); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(consoleSpy).toHaveBeenCalled(); + expect(pushErrorNotification).toHaveBeenCalledWith('An error occurred during import'); + consoleSpy.mockRestore(); + }); + + it('should send correct FormData with file and csrf token', async () => { + setupBasicDom(); + + const mockFile = new File(['content'], 'test.csv', { type: 'text/csv' }); + const fileInput = document.getElementById('fileInputCSVUpload') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { + value: { 0: mockFile, length: 1, item: () => mockFile }, + writable: true, + }); + + const mockFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: 'Done' }), + } as Response); + + await handleUploadCSVForm(); + + const button = document.getElementById('submitButton') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const formData = mockFetch.mock.calls[0][1]?.body as FormData; + expect(formData.get('csrf')).toBe('test-csrf-token'); + expect(formData.get('file')).toBeTruthy(); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/editor.test.ts b/phpmyfaq/admin/assets/src/content/editor.test.ts new file mode 100644 index 0000000000..d2aeb7ec9e --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/editor.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock matchMedia before any imports that might use it +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock MutationObserver +class MockMutationObserver { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(); +} +global.MutationObserver = MockMutationObserver as unknown as typeof MutationObserver; + +// Mock jodit and its plugins +const mockEventsOn = vi.fn(); +const mockQuerySelectorAll = vi.fn(() => []); +const mockClassListRemove = vi.fn(); +const mockClassListAdd = vi.fn(); +const mockInsertImage = vi.fn(); +const mockInsertHTML = vi.fn(); + +const mockEditorInstance = { + options: {}, + container: { + querySelectorAll: mockQuerySelectorAll, + classList: { + remove: mockClassListRemove, + add: mockClassListAdd, + }, + }, + events: { + on: mockEventsOn, + }, + value: '', + selection: { + insertImage: mockInsertImage, + insertHTML: mockInsertHTML, + }, +}; + +vi.mock('jodit', () => ({ + Jodit: { + make: vi.fn(() => mockEditorInstance), + }, +})); + +vi.mock('highlight.js', () => ({ + default: { + highlightElement: vi.fn(), + }, +})); + +vi.mock('jodit/esm/plugins/class-span/class-span.js', () => ({})); +vi.mock('jodit/esm/plugins/clean-html/clean-html.js', () => ({})); +vi.mock('jodit/esm/plugins/clipboard/clipboard.js', () => ({})); +vi.mock('jodit/esm/plugins/copy-format/copy-format.js', () => ({})); +vi.mock('jodit/esm/plugins/delete/delete.js', () => ({})); +vi.mock('jodit/esm/plugins/fullsize/fullsize.js', () => ({})); +vi.mock('jodit/esm/plugins/hr/hr.js', () => ({})); +vi.mock('jodit/esm/plugins/image/image.js', () => ({})); +vi.mock('jodit/esm/plugins/image-processor/image-processor.js', () => ({})); +vi.mock('jodit/esm/plugins/image-properties/image-properties.js', () => ({})); +vi.mock('jodit/esm/plugins/indent/indent.js', () => ({})); +vi.mock('jodit/esm/plugins/justify/justify.js', () => ({})); +vi.mock('jodit/esm/plugins/line-height/line-height.js', () => ({})); +vi.mock('jodit/esm/plugins/media/media.js', () => ({})); +vi.mock('jodit/esm/plugins/paste-storage/paste-storage.js', () => ({})); +vi.mock('jodit/esm/plugins/paste-from-word/paste-from-word.js', () => ({})); +vi.mock('jodit/esm/plugins/preview/preview.js', () => ({})); +vi.mock('jodit/esm/plugins/print/print.js', () => ({})); +vi.mock('jodit/esm/plugins/resizer/resizer.js', () => ({})); +vi.mock('jodit/esm/plugins/search/search.js', () => ({})); +vi.mock('jodit/esm/plugins/select/select.js', () => ({})); +vi.mock('jodit/esm/plugins/source/source.js', () => ({})); +vi.mock('jodit/esm/plugins/symbols/symbols.js', () => ({})); +vi.mock('jodit/esm/modules/uploader/uploader.js', () => ({})); +vi.mock('jodit/esm/plugins/video/video.js', () => ({})); +vi.mock('../plugins/phpmyfaq/phpmyfaq.js', () => ({})); +vi.mock('../plugins/code-snippet/code-snippet.js', () => ({})); + +import { getJoditEditor, renderEditor, renderPageEditor } from './editor'; +import { Jodit } from 'jodit'; + +describe('Editor', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + + // Reset the mock editor instance properties + mockEditorInstance.options = {}; + mockEditorInstance.value = ''; + }); + + describe('getJoditEditor', () => { + it('should return null initially', () => { + // Before any render call, the module-level joditEditorInstance is null. + // Since vitest caches module state, we verify the function is callable. + const result = getJoditEditor(); + expect(result === null || result === mockEditorInstance).toBe(true); + }); + }); + + describe('renderEditor', () => { + it('should return early when #editor element does not exist', () => { + document.body.innerHTML = '
'; + + renderEditor(); + + expect(Jodit.make).not.toHaveBeenCalled(); + }); + + it('should call Jodit.make when #editor element exists', () => { + document.body.innerHTML = ` +
+ + `; + + renderEditor(); + + expect(Jodit.make).toHaveBeenCalled(); + const callArgs = (Jodit.make as ReturnType).mock.calls[0]; + const editorElement = callArgs[0] as HTMLElement; + expect(editorElement.id).toBe('editor'); + }); + + it('should register event listeners on the editor after creation', () => { + document.body.innerHTML = ` +
+ + `; + + renderEditor(); + + expect(mockEventsOn).toHaveBeenCalledWith('afterSetValue', expect.any(Function)); + expect(mockEventsOn).toHaveBeenCalledWith('change', expect.any(Function)); + }); + }); + + describe('renderPageEditor', () => { + it('should return early when #content element does not exist', () => { + document.body.innerHTML = '
'; + + renderPageEditor(); + + expect(Jodit.make).not.toHaveBeenCalled(); + }); + + it('should return early when #content exists but parent .mb-3 does not exist', () => { + document.body.innerHTML = ` +
+ +
+ `; + + renderPageEditor(); + + expect(Jodit.make).not.toHaveBeenCalled(); + }); + + it('should call Jodit.make when #content and parent .mb-3 exist', () => { + document.body.innerHTML = ` +
+ +
+ `; + + renderPageEditor(); + + expect(Jodit.make).toHaveBeenCalled(); + const callArgs = (Jodit.make as ReturnType).mock.calls[0]; + const contentElement = callArgs[0] as HTMLTextAreaElement; + expect(contentElement.id).toBe('content'); + }); + + it('should register event listeners on the editor after creation', () => { + document.body.innerHTML = ` +
+ +
+ `; + + renderPageEditor(); + + expect(mockEventsOn).toHaveBeenCalledWith('afterInit', expect.any(Function)); + expect(mockEventsOn).toHaveBeenCalledWith('change', expect.any(Function)); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/editor.ts b/phpmyfaq/admin/assets/src/content/editor.ts index 339890eefe..cf8bdd0069 100644 --- a/phpmyfaq/admin/assets/src/content/editor.ts +++ b/phpmyfaq/admin/assets/src/content/editor.ts @@ -341,3 +341,242 @@ export const renderEditor = () => { // Store the editor instance so it can be accessed by other modules joditEditorInstance = joditEditor; }; + +export const renderPageEditor = () => { + const contentField = document.getElementById('content') as HTMLTextAreaElement | null; + if (!contentField) { + return; + } + + // Check if editor container wrapper exists - if not, don't initialize + const parentDiv = contentField.closest('.mb-3'); + if (!parentDiv) { + return; + } + + // Detect browser color scheme preference (dark/light) + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + + const joditEditor = Jodit.make(contentField, { + zIndex: 0, + readonly: false, + beautifyHTML: false, + sourceEditor: 'area', + activeButtonsInReadOnly: ['source', 'fullsize', 'print', 'about', 'dots'], + toolbarButtonSize: 'middle', + theme: prefersDark.matches ? 'dark' : 'default', + saveModeInStorage: false, + spellcheck: true, + editorClassName: false, + triggerChangeEvent: true, + width: 'auto', + height: 500, + minHeight: 300, + maxHeight: 800, + direction: '', + language: 'auto', + debugLanguage: false, + tabIndex: -1, + toolbar: true, + enter: 'p', + defaultMode: 1, // MODE_WYSIWYG + useSplitMode: false, + askBeforePasteFromWord: true, + processPasteFromWord: true, + defaultActionOnPasteFromWord: 'insert_clear_html', + colors: { + greyscale: [ + '#000000', + '#434343', + '#666666', + '#999999', + '#B7B7B7', + '#CCCCCC', + '#D9D9D9', + '#EFEFEF', + '#F3F3F3', + '#FFFFFF', + ], + palette: [ + '#980000', + '#FF0000', + '#FF9900', + '#FFFF00', + '#00F0F0', + '#00FFFF', + '#4A86E8', + '#0000FF', + '#9900FF', + '#FF00FF', + ], + full: [ + '#E6B8AF', + '#F4CCCC', + '#FCE5CD', + '#FFF2CC', + '#D9EAD3', + '#D0E0E3', + '#C9DAF8', + '#CFE2F3', + '#D9D2E9', + '#EAD1DC', + '#DD7E6B', + '#EA9999', + '#F9CB9C', + '#FFE599', + '#B6D7A8', + '#A2C4C9', + '#A4C2F4', + '#9FC5E8', + '#B4A7D6', + '#D5A6BD', + '#CC4125', + '#E06666', + '#F6B26B', + '#FFD966', + '#93C47D', + '#76A5AF', + '#6D9EEB', + '#6FA8DC', + '#8E7CC3', + '#C27BA0', + '#A61C00', + '#CC0000', + '#E69138', + '#F1C232', + '#6AA84F', + '#45818E', + '#3C78D8', + '#3D85C6', + '#674EA7', + '#A64D79', + '#85200C', + '#990000', + '#B45F06', + '#BF9000', + '#38761D', + '#134F5C', + '#1155CC', + '#0B5394', + '#351C75', + '#733554', + '#5B0F00', + '#660000', + '#783F04', + '#7F6000', + '#274E13', + '#0C343D', + '#1C4587', + '#073763', + '#20124D', + '#4C1130', + ], + }, + colorPickerDefaultTab: 'background', + imageDefaultWidth: 300, + imageProcessor: { replaceDataURIToBlobIdInView: false }, + removeButtons: [], + disablePlugins: [], + extraPlugins: ['phpMyFAQ', 'codeSnippet'], + extraButtons: [], + buttons: [ + 'source', + '|', + 'bold', + 'strikethrough', + 'underline', + 'italic', + '|', + 'ul', + 'ol', + '|', + 'font', + 'fontsize', + 'brush', + 'paragraph', + '|', + 'image', + 'video', + 'table', + 'link', + '|', + 'left', + 'center', + 'right', + 'justify', + '|', + 'undo', + 'redo', + '|', + 'hr', + 'eraser', + 'copyformat', + '|', + 'symbol', + 'fullsize', + 'print', + ], + controls: {}, + placeholder: '', + showPlaceholder: true, + popup: {}, + uploader: { + url: './api/image/upload', + format: 'json', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + prepareData: function (formData: FormData) { + return formData; + }, + isSuccess: function (response: UploaderResponse): boolean { + return response.success === true; + }, + getMessage: function (response: UploaderResponse): string { + return response.msg || ''; + }, + process: function (response: UploaderResponse): { + files: string[]; + error?: string; + msg?: string; + } { + return { + files: response.data?.files || [], + error: response.error, + msg: response.msg, + }; + }, + error: function (error: Error): void { + console.error('Upload error:', error); + }, + defaultHandlerSuccess: function (response: UploaderResponse): void { + if (response.data?.files) { + response.data.files.forEach((filename: string, index: number) => { + const isImage = response.data?.isImages?.[index] ?? false; + if (isImage) { + joditEditor.selection.insertImage(filename, null, 300); + } else { + joditEditor.selection.insertHTML(`${filename}`); + } + }); + } + }, + }, + }); + + // Syntax highlighting for code blocks + joditEditor.events.on('afterInit', (): void => { + joditEditor.container.querySelectorAll('pre code').forEach((block: Element): void => { + hljs.highlightElement(block as HTMLElement); + }); + }); + + joditEditor.events.on('change', (): void => { + joditEditor.container.querySelectorAll('pre code').forEach((block: Element): void => { + hljs.highlightElement(block as HTMLElement); + }); + }); + + // Store the editor instance + joditEditorInstance = joditEditor; +}; diff --git a/phpmyfaq/admin/assets/src/content/faqs.autocomplete.test.ts b/phpmyfaq/admin/assets/src/content/faqs.autocomplete.test.ts new file mode 100644 index 0000000000..4c8eb4eb50 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/faqs.autocomplete.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; + +const mockAutocomplete = vi.fn(); + +vi.mock('autocompleter', () => ({ default: mockAutocomplete })); +vi.mock('../api', () => ({ fetchFaqsByAutocomplete: vi.fn() })); +vi.mock('../../../../assets/src/utils', () => ({ + addElement: (tag: string, properties: Record = {}, children: Node[] = []) => { + const element = Object.assign(document.createElement(tag), properties); + Object.keys(properties).forEach((key: string): void => { + if (key.startsWith('data-')) { + const dataKey: string = key.replace('data-', ''); + element.dataset[dataKey] = properties[key] as string; + } + }); + children.forEach((child: Node): Node => element.appendChild(child)); + return element; + }, +})); + +describe('faqs.autocomplete', () => { + let domContentLoadedCallback: (() => void) | null = null; + + beforeEach(() => { + vi.resetModules(); + mockAutocomplete.mockClear(); + document.body.innerHTML = ''; + domContentLoadedCallback = null; + + // Spy on addEventListener to capture the DOMContentLoaded callback + const originalAddEventListener = document.addEventListener.bind(document); + vi.spyOn(document, 'addEventListener').mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { + if (type === 'DOMContentLoaded' && typeof listener === 'function') { + domContentLoadedCallback = listener as () => void; + } else { + originalAddEventListener(type, listener, options); + } + } + ); + }); + + const importAndTrigger = async (): Promise => { + await import('./faqs.autocomplete'); + if (domContentLoadedCallback) { + domContentLoadedCallback(); + } + }; + + it('should not initialize autocomplete when search input is missing', async () => { + document.body.innerHTML = '
'; + + await importAndTrigger(); + + expect(mockAutocomplete).not.toHaveBeenCalled(); + }); + + it('should initialize autocomplete when search input exists', async () => { + document.body.innerHTML = ` + + + `; + + await importAndTrigger(); + + expect(mockAutocomplete).toHaveBeenCalledTimes(1); + }); + + it('should pass correct configuration to autocomplete', async () => { + document.body.innerHTML = ` + + + `; + + await importAndTrigger(); + + expect(mockAutocomplete).toHaveBeenCalledTimes(1); + + const config = mockAutocomplete.mock.calls[0][0] as Record; + const inputElement = document.getElementById('pmf-faq-overview-search-input'); + + expect(config.input).toBe(inputElement); + expect(config.minLength).toBe(1); + expect(config.emptyMsg).toBe('No users found'); + expect(typeof config.onSelect).toBe('function'); + expect(typeof config.fetch).toBe('function'); + expect(typeof config.render).toBe('function'); + }); + + it('should pass onSelect that navigates to adminUrl', async () => { + document.body.innerHTML = ` + + + `; + + const hrefSetter = vi.fn(); + Object.defineProperty(window, 'location', { + value: { href: '' }, + writable: true, + configurable: true, + }); + Object.defineProperty(window.location, 'href', { + set: hrefSetter, + get: () => '', + configurable: true, + }); + + await importAndTrigger(); + + const config = mockAutocomplete.mock.calls[0][0] as Record; + const onSelect = config.onSelect as (item: { adminUrl: string }) => void; + + onSelect({ adminUrl: '/admin/faq/edit/1' }); + + expect(hrefSetter).toHaveBeenCalledWith('/admin/faq/edit/1'); + }); + + it('should pass render that creates a div with highlighted match', async () => { + document.body.innerHTML = ` + + + `; + + await importAndTrigger(); + + const config = mockAutocomplete.mock.calls[0][0] as Record; + const render = config.render as (item: { question: string }, currentValue: string) => HTMLDivElement; + + const div = render({ question: 'How to install phpMyFAQ?' }, 'install'); + + expect(div.tagName).toBe('DIV'); + expect(div.classList.contains('pmf-faq-list-result')).toBe(true); + expect(div.classList.contains('border')).toBe(true); + expect(div.innerHTML).toContain('install'); + expect(div.innerHTML).toContain('How to'); + expect(div.innerHTML).toContain('phpMyFAQ?'); + }); + + it('should pass fetch that calls fetchFaqsByAutocomplete and filters results', async () => { + document.body.innerHTML = ` + + + `; + + const { fetchFaqsByAutocomplete } = await import('../api'); + (fetchFaqsByAutocomplete as Mock).mockResolvedValue({ + success: [ + { question: 'How to install?', adminUrl: '/admin/1' }, + { question: 'How to configure?', adminUrl: '/admin/2' }, + { question: 'Release notes', adminUrl: '/admin/3' }, + ], + }); + + await importAndTrigger(); + + const config = mockAutocomplete.mock.calls[0][0] as Record; + const fetchFn = config.fetch as ( + text: string, + update: (items: Array<{ question: string; adminUrl: string }>) => void + ) => Promise; + + const update = vi.fn(); + await fetchFn('how', update); + + expect(fetchFaqsByAutocomplete).toHaveBeenCalledWith('how', 'csrf-789'); + expect(update).toHaveBeenCalledTimes(1); + + const filteredItems = update.mock.calls[0][0] as Array<{ question: string }>; + expect(filteredItems).toHaveLength(2); + expect(filteredItems[0].question).toBe('How to install?'); + expect(filteredItems[1].question).toBe('How to configure?'); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/faqs.autocomplete.ts b/phpmyfaq/admin/assets/src/content/faqs.autocomplete.ts index 49a1069ebb..4ae7cfbbeb 100644 --- a/phpmyfaq/admin/assets/src/content/faqs.autocomplete.ts +++ b/phpmyfaq/admin/assets/src/content/faqs.autocomplete.ts @@ -37,7 +37,7 @@ document.addEventListener('DOMContentLoaded', () => { }, fetch: async (text: string, update: (items: FaqItem[]) => void) => { const match = text.toLowerCase(); - const faqs = await fetchFaqsByAutocomplete(match, csrfToken); + const faqs = (await fetchFaqsByAutocomplete(match, csrfToken)) as { success: FaqItem[] }; update( faqs.success.filter((n: FaqItem) => { return n.question.toLowerCase().indexOf(match) !== -1; diff --git a/phpmyfaq/admin/assets/src/content/faqs.editor.test.ts b/phpmyfaq/admin/assets/src/content/faqs.editor.test.ts new file mode 100644 index 0000000000..c498f9d1bb --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/faqs.editor.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import { + handleSaveFaqData, + handleDeleteFaqEditorModal, + handleUpdateQuestion, + handleResetButton, + handleFleschReadingEase, +} from './faqs.editor'; +import { create, update, deleteFaq } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; +import { analyzeReadability } from '../utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); +vi.mock('./editor', () => ({ + getJoditEditor: vi.fn(() => null), +})); +vi.mock('../utils', () => ({ + analyzeReadability: vi.fn(() => ({ score: 65, label: 'Standard', colorClass: 'primary' })), +})); + +describe('faqs.editor', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('handleSaveFaqData', () => { + it('should do nothing when submit button is missing', () => { + document.body.innerHTML = '
'; + + handleSaveFaqData(); + + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it('should call create() when faqId is "0" and update inputs on success', async () => { + document.body.innerHTML = ` +
+ + +
+ + + + `; + + const responseData = JSON.stringify({ id: '42', revisionId: '1' }); + (create as Mock).mockResolvedValue({ success: 'FAQ created', data: responseData }); + + handleSaveFaqData(); + + const button = document.getElementById('faqEditorSubmit') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ faqId: '0', question: 'Test question' })); + expect(update).not.toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('FAQ created'); + + const faqIdInput = document.getElementById('faqId') as HTMLInputElement; + const revisionIdInput = document.getElementById('revisionId') as HTMLInputElement; + expect(faqIdInput.value).toBe('42'); + expect(revisionIdInput.value).toBe('1'); + }); + + it('should call update() when faqId is not "0"', async () => { + document.body.innerHTML = ` +
+ + +
+ + + + `; + + const responseData = JSON.stringify({ id: '10', revisionId: '2' }); + (update as Mock).mockResolvedValue({ success: 'FAQ updated', data: responseData }); + + handleSaveFaqData(); + + const button = document.getElementById('faqEditorSubmit') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ faqId: '10', question: 'Updated question' })); + expect(create).not.toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('FAQ updated'); + }); + + it('should show error notification on error response', async () => { + document.body.innerHTML = ` +
+ +
+ + + + `; + + (create as Mock).mockResolvedValue({ error: 'Validation failed' }); + + handleSaveFaqData(); + + const button = document.getElementById('faqEditorSubmit') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Validation failed'); + expect(pushNotification).not.toHaveBeenCalled(); + }); + }); + + describe('handleDeleteFaqEditorModal', () => { + it('should do nothing when buttons are missing', () => { + document.body.innerHTML = '
'; + + handleDeleteFaqEditorModal(); + + expect(deleteFaq).not.toHaveBeenCalled(); + }); + + it('should call deleteFaq with correct params and show notification', async () => { + document.body.innerHTML = ` + + + `; + + (deleteFaq as Mock).mockResolvedValue({ success: 'FAQ deleted successfully' }); + + handleDeleteFaqEditorModal(); + + const confirmButton = document.getElementById('pmf-confirm-delete-faq') as HTMLButtonElement; + confirmButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteFaq).toHaveBeenCalledWith('42', 'en', 'test-csrf'); + expect(pushNotification).toHaveBeenCalledWith('FAQ deleted successfully'); + }); + + it('should show error notification when required params are missing', async () => { + document.body.innerHTML = ` + + + `; + + handleDeleteFaqEditorModal(); + + const confirmButton = document.getElementById('pmf-confirm-delete-faq') as HTMLButtonElement; + confirmButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteFaq).not.toHaveBeenCalled(); + expect(pushErrorNotification).toHaveBeenCalledWith('Fehlende Parameter zum L\u00f6schen der FAQ.'); + }); + }); + + describe('handleUpdateQuestion', () => { + it('should do nothing when input is missing', () => { + document.body.innerHTML = '
'; + + handleUpdateQuestion(); + + const output = document.getElementById('pmf-admin-question-output'); + expect(output).toBeNull(); + }); + + it('should update output on input event', () => { + document.body.innerHTML = ` + + + `; + + handleUpdateQuestion(); + + const input = document.getElementById('question') as HTMLInputElement; + input.value = 'What is phpMyFAQ?'; + input.dispatchEvent(new Event('input')); + + const output = document.getElementById('pmf-admin-question-output') as HTMLElement; + expect(output.innerText).toBe(': What is phpMyFAQ?'); + }); + }); + + describe('handleResetButton', () => { + it('should do nothing when reset button is missing', () => { + document.body.innerHTML = '
'; + + handleResetButton(); + + expect(document.body.innerHTML).toBe('
'); + }); + + it('should reset form and restore defaults', () => { + document.body.innerHTML = ` +
+ + + + : Modified question + + +
+ `; + + // Modify current values away from defaults + const questionInput = document.getElementById('question') as HTMLInputElement; + questionInput.value = 'Modified question'; + + const markdownTextarea = document.getElementById('answer-markdown') as HTMLTextAreaElement; + markdownTextarea.value = 'Modified markdown'; + + const revisionSelect = document.getElementById('selectedRevisionId') as HTMLSelectElement; + revisionSelect.value = '1'; + + handleResetButton(); + + const resetButton = document.querySelector('button[type="reset"]') as HTMLButtonElement; + resetButton.click(); + + // Markdown textarea should be restored to its defaultValue + expect(markdownTextarea.value).toBe('Original markdown'); + + // Question output should show the original question default value + const questionOutput = document.getElementById('pmf-admin-question-output') as HTMLElement; + expect(questionOutput.innerText).toBe(': Original question'); + + // Revision select should be set to the last option + expect(revisionSelect.value).toBe('3'); + }); + }); + + describe('handleFleschReadingEase', () => { + it('should do nothing when required elements are missing', () => { + document.body.innerHTML = '
'; + + handleFleschReadingEase(); + + expect(analyzeReadability).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/faqs.editor.ts b/phpmyfaq/admin/assets/src/content/faqs.editor.ts index 998fffd860..aca98b2195 100644 --- a/phpmyfaq/admin/assets/src/content/faqs.editor.ts +++ b/phpmyfaq/admin/assets/src/content/faqs.editor.ts @@ -17,6 +17,7 @@ import { create, update, deleteFaq } from '../api'; import { pushErrorNotification, pushNotification, serialize } from '../../../../assets/src/utils'; import { Response } from '../interfaces'; import { getJoditEditor } from './editor'; +import { analyzeReadability, SupportedLanguage } from '../utils'; interface SerializedData { faqId: string; @@ -36,9 +37,9 @@ export const handleSaveFaqData = (): void => { let response: Response | undefined; if (serializedData.faqId === '0') { - response = await create(serializedData); + response = (await create(serializedData)) as Response | undefined; } else { - response = await update(serializedData); + response = (await update(serializedData)) as Response | undefined; } if (response?.success) { @@ -80,7 +81,7 @@ export const handleDeleteFaqEditorModal = (): void => { } try { - const response = await deleteFaq(faqId, faqLanguage, csrfToken); + const response = (await deleteFaq(faqId, faqLanguage, csrfToken)) as Response | undefined; if (response?.success) { pushNotification(response.success); @@ -156,3 +157,139 @@ export const handleResetButton = (): void => { }); } }; + +/** + * Debounce function for performance + */ +const debounce = void>(func: T, wait: number): ((...args: Parameters) => void) => { + let timeout: ReturnType; + return (...args: Parameters): void => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +}; + +/** + * Handles real-time Flesch Reading Ease calculation + */ +export const handleFleschReadingEase = (): void => { + const fleschScoreElement = document.getElementById('pmf-flesch-score') as HTMLElement | null; + const fleschLabelElement = document.getElementById('pmf-flesch-label') as HTMLElement | null; + const fleschBadgeElement = document.getElementById('pmf-flesch-badge') as HTMLElement | null; + + if (!fleschScoreElement || !fleschLabelElement || !fleschBadgeElement) { + return; + } + + /** + * Gets the current FAQ language from the form + * Maps language codes to supported Flesch formula languages + */ + const getLanguage = (): SupportedLanguage => { + const langSelect = document.getElementById('lang') as HTMLSelectElement | HTMLInputElement | null; + if (!langSelect) { + return 'en'; + } + + const lang = langSelect.value.toLowerCase().split(/[-_]/)[0]; + + const languageMap: Record = { + de: 'de', + en: 'en', + es: 'es', + fr: 'fr', + it: 'it', + nl: 'nl', + pt: 'pt', + pl: 'pl', + ru: 'ru', + cs: 'cs', + tr: 'tr', + sv: 'sv', + da: 'da', + no: 'no', + nb: 'no', // Norwegian Bokmål + nn: 'no', // Norwegian Nynorsk + fi: 'fi', + }; + + return languageMap[lang] || 'en'; + }; + + /** + * Updates the Flesch score display + */ + const updateFleschDisplay = (content: string): void => { + const language = getLanguage(); + const result = analyzeReadability(content, language); + + fleschScoreElement.textContent = result.score.toString(); + fleschLabelElement.textContent = result.label; + + // Update badge color + fleschBadgeElement.className = `badge bg-${result.colorClass}`; + }; + + const debouncedUpdate = debounce(updateFleschDisplay, 300); + + /** + * Gets content from available editor + */ + const getEditorContent = (): string => { + const joditEditor = getJoditEditor(); + if (joditEditor) { + return joditEditor.value; + } + + const markdownEditor = document.getElementById('answer-markdown') as HTMLTextAreaElement | null; + if (markdownEditor) { + return markdownEditor.value; + } + + const plainEditor = document.getElementById('editor') as HTMLTextAreaElement | null; + if (plainEditor) { + return plainEditor.value; + } + + return ''; + }; + + // Initial calculation + const initialContent = getEditorContent(); + if (initialContent) { + updateFleschDisplay(initialContent); + } + + // Handle Jodit WYSIWYG editor + const joditEditor = getJoditEditor(); + if (joditEditor) { + joditEditor.events.on('change', (): void => { + debouncedUpdate(joditEditor.value); + }); + } + + // Handle Markdown editor + const markdownEditor = document.getElementById('answer-markdown') as HTMLTextAreaElement | null; + if (markdownEditor) { + markdownEditor.addEventListener('input', (): void => { + debouncedUpdate(markdownEditor.value); + }); + } + + // Handle plain text editor (fallback when no Jodit) + const plainEditor = document.getElementById('editor') as HTMLTextAreaElement | null; + if (plainEditor && !joditEditor) { + plainEditor.addEventListener('input', (): void => { + debouncedUpdate(plainEditor.value); + }); + } + + // Re-calculate when language changes + const langSelect = document.getElementById('lang') as HTMLSelectElement | null; + if (langSelect) { + langSelect.addEventListener('change', (): void => { + const content = getEditorContent(); + updateFleschDisplay(content); + }); + } +}; diff --git a/phpmyfaq/admin/assets/src/content/faqs.overview.test.ts b/phpmyfaq/admin/assets/src/content/faqs.overview.test.ts new file mode 100644 index 0000000000..6ed2acb936 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/faqs.overview.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleDeleteFaqModal, handleFaqOverview } from './faqs.overview'; +import { deleteFaq, fetchAllFaqsByCategory } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +const mockModalShow = vi.fn(); +const mockModalHide = vi.fn(); + +vi.mock('bootstrap', () => { + const ModalClass = vi.fn().mockImplementation(function (this: Record) { + this.show = mockModalShow; + this.hide = mockModalHide; + }); + return { Modal: ModalClass }; +}); +vi.mock('../api'); +vi.mock('../utils'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('FAQ Overview Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + localStorage.clear(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('handleDeleteFaqModal', () => { + it('should do nothing when modal element is missing', () => { + document.body.innerHTML = '
'; + + handleDeleteFaqModal(); + + expect(deleteFaq).not.toHaveBeenCalled(); + }); + + it('should store data and show modal when .pmf-button-delete-faq is clicked', async () => { + document.body.innerHTML = ` +
+ + + `; + + handleDeleteFaqModal(); + + const deleteButton = document.querySelector('.pmf-button-delete-faq') as HTMLElement; + deleteButton.click(); + + await flushPromises(); + + expect(mockModalShow).toHaveBeenCalled(); + }); + + it('should call deleteFaq, remove row, show notification, and hide modal on confirm', async () => { + document.body.innerHTML = ` +
+ + + FAQ row + `; + + (deleteFaq as Mock).mockResolvedValue({ success: 'FAQ deleted successfully' }); + + handleDeleteFaqModal(); + + // Click the delete button to store data + const deleteButton = document.querySelector('.pmf-button-delete-faq') as HTMLElement; + deleteButton.click(); + + await flushPromises(); + + // Click confirm to trigger deletion + const confirmButton = document.getElementById('confirmDeleteFaqButton') as HTMLButtonElement; + confirmButton.click(); + + await flushPromises(); + + expect(deleteFaq).toHaveBeenCalledWith('42', 'en', 'csrf-abc'); + expect(pushNotification).toHaveBeenCalledWith('FAQ deleted successfully'); + expect(document.getElementById('faq_42_en')).toBeNull(); + expect(mockModalHide).toHaveBeenCalled(); + }); + + it('should show error notification on API failure', async () => { + document.body.innerHTML = ` +
+ + + `; + + (deleteFaq as Mock).mockRejectedValue(new Error('Network error')); + + handleDeleteFaqModal(); + + // Click the delete button to store data + const deleteButton = document.querySelector('.pmf-button-delete-faq') as HTMLElement; + deleteButton.click(); + + await flushPromises(); + + // Click confirm to trigger deletion + const confirmButton = document.getElementById('confirmDeleteFaqButton') as HTMLButtonElement; + confirmButton.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Fehler beim Löschen der FAQ'); + }); + + it('should do nothing when currentFaqId, language, and token are empty', async () => { + document.body.innerHTML = ` +
+ + `; + + handleDeleteFaqModal(); + + // Click confirm without first clicking a delete button (no data stored) + const confirmButton = document.getElementById('confirmDeleteFaqButton') as HTMLButtonElement; + confirmButton.click(); + + await flushPromises(); + + expect(deleteFaq).not.toHaveBeenCalled(); + }); + }); + + describe('handleFaqOverview', () => { + it('should do nothing when no .accordion-collapse elements exist', async () => { + document.body.innerHTML = '
'; + + await handleFaqOverview(); + + expect(fetchAllFaqsByCategory).not.toHaveBeenCalled(); + }); + + it('should initialize checkbox state from localStorage', async () => { + localStorage.setItem('pmfCheckboxFilterInactive', 'true'); + localStorage.setItem('pmfCheckboxFilterNew', 'false'); + + document.body.innerHTML = ` + + +
+ `; + + await handleFaqOverview(); + + const inactiveCheckbox = document.getElementById('pmf-checkbox-filter-inactive') as HTMLInputElement; + const newCheckbox = document.getElementById('pmf-checkbox-filter-new') as HTMLInputElement; + + expect(inactiveCheckbox.checked).toBe(true); + expect(newCheckbox.checked).toBe(false); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/faqs.test.ts b/phpmyfaq/admin/assets/src/content/faqs.test.ts new file mode 100644 index 0000000000..fe3c876867 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/faqs.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleFaqForm, handleFaqTranslate } from './faqs'; +import { deleteAttachments } from '../api'; +import { pushNotification } from '../../../../assets/src/utils'; +import { Translator } from '../translation/translator'; + +vi.mock('../api'); +vi.mock('../translation/translator'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)); +}; + +describe('handleFaqForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('should not throw when no elements exist', () => { + document.body.innerHTML = '
'; + + expect(() => handleFaqForm()).not.toThrow(); + }); + + it('should show help element when tags input is focused', () => { + document.body.innerHTML = ` + +
Tags help text
+ `; + + handleFaqForm(); + + const tagsInput = document.getElementById('tags') as HTMLInputElement; + tagsInput.dispatchEvent(new Event('focus')); + + const tagsHelp = document.getElementById('tagsHelp') as HTMLElement; + expect(tagsHelp.classList.contains('visually-hidden')).toBe(false); + }); + + it('should show help element when keywords input is focused', () => { + document.body.innerHTML = ` + +
Keywords help text
+ `; + + handleFaqForm(); + + const keywordsInput = document.getElementById('keywords') as HTMLInputElement; + keywordsInput.dispatchEvent(new Event('focus')); + + const keywordsHelp = document.getElementById('keywordsHelp') as HTMLElement; + expect(keywordsHelp.classList.contains('visually-hidden')).toBe(false); + }); + + it('should call deleteAttachments and show success notification on successful delete', async () => { + document.body.innerHTML = ` + +
Attachment item
+ `; + + (deleteAttachments as Mock).mockResolvedValue({ success: 'Attachment deleted' }); + + handleFaqForm(); + + const button = document.querySelector('.pmf-delete-attachment-button') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteAttachments).toHaveBeenCalledWith('99', 'csrf-abc'); + expect(pushNotification).toHaveBeenCalledWith('Attachment deleted'); + }); + + it('should show error notification when deleteAttachments returns error', async () => { + document.body.innerHTML = ` + + `; + + (deleteAttachments as Mock).mockResolvedValue({ error: 'Delete failed' }); + + handleFaqForm(); + + const button = document.querySelector('.pmf-delete-attachment-button') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteAttachments).toHaveBeenCalledWith('99', 'csrf-abc'); + expect(pushNotification).toHaveBeenCalledWith('Delete failed'); + }); + + it('should show warning and disable submit when question contains #', () => { + document.body.innerHTML = ` + +
Hash warning
+ + `; + + handleFaqForm(); + + const questionInput = document.getElementById('question') as HTMLInputElement; + questionInput.value = 'What is #something?'; + questionInput.dispatchEvent(new Event('input')); + + const questionHelp = document.getElementById('questionHelp') as HTMLElement; + const submitButton = document.getElementById('faqEditorSubmit') as HTMLButtonElement; + + expect(questionHelp.classList.contains('visually-hidden')).toBe(false); + expect(submitButton.getAttribute('disabled')).toBe('true'); + }); + + it('should hide warning and enable submit when # is removed from question', () => { + document.body.innerHTML = ` + +
Hash warning
+ + `; + + handleFaqForm(); + + const questionInput = document.getElementById('question') as HTMLInputElement; + const questionHelp = document.getElementById('questionHelp') as HTMLElement; + const submitButton = document.getElementById('faqEditorSubmit') as HTMLButtonElement; + + // First type a hash + questionInput.value = 'What is #something?'; + questionInput.dispatchEvent(new Event('input')); + + expect(questionHelp.classList.contains('visually-hidden')).toBe(false); + expect(submitButton.getAttribute('disabled')).toBe('true'); + + // Now remove the hash + questionInput.value = 'What is something?'; + questionInput.dispatchEvent(new Event('input')); + + expect(questionHelp.classList.contains('visually-hidden')).toBe(true); + expect(submitButton.hasAttribute('disabled')).toBe(false); + }); +}); + +describe('handleFaqTranslate', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('should not throw when elements are missing', () => { + document.body.innerHTML = '
'; + + expect(() => handleFaqTranslate()).not.toThrow(); + }); + + it('should enable translate button and create Translator when different languages are selected', () => { + document.body.innerHTML = ` + + + + `; + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleFaqTranslate(); + + const langSelect = document.getElementById('lang') as HTMLSelectElement; + const translateButton = document.getElementById('btn-translate-faq-ai') as HTMLButtonElement; + + // Button should be initially disabled + expect(translateButton.disabled).toBe(true); + + // Select a different language + langSelect.value = 'de'; + langSelect.dispatchEvent(new Event('change')); + + expect(translateButton.disabled).toBe(false); + expect(Translator).toHaveBeenCalledWith( + expect.objectContaining({ + buttonSelector: '#btn-translate-faq-ai', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + }) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should disable translate button when same language is selected', () => { + document.body.innerHTML = ` + + + + `; + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + handleFaqTranslate(); + + const langSelect = document.getElementById('lang') as HTMLSelectElement; + const translateButton = document.getElementById('btn-translate-faq-ai') as HTMLButtonElement; + + // First select a different language to enable + langSelect.value = 'de'; + langSelect.dispatchEvent(new Event('change')); + expect(translateButton.disabled).toBe(false); + + // Now select the same language as source + langSelect.value = 'en'; + langSelect.dispatchEvent(new Event('change')); + expect(translateButton.disabled).toBe(true); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/faqs.ts b/phpmyfaq/admin/assets/src/content/faqs.ts index 6117affc80..03b6b91d0b 100644 --- a/phpmyfaq/admin/assets/src/content/faqs.ts +++ b/phpmyfaq/admin/assets/src/content/faqs.ts @@ -14,7 +14,9 @@ */ import { deleteAttachments } from '../api'; -import { pushNotification } from '../../../../assets/src/utils'; +import { pushNotification, pushErrorNotification } from '../../../../assets/src/utils'; +import { Response } from '../interfaces'; +import { Translator } from '../translation/translator'; const showHelp = (option: string): void => { const optionHelp = document.getElementById(`${option}Help`) as HTMLElement; @@ -43,7 +45,7 @@ export const handleFaqForm = (): void => { const attachmentId = target.getAttribute('data-pmf-attachment-id') as string; const csrfToken = target.getAttribute('data-pmf-csrf-token') as string; - const response = await deleteAttachments(attachmentId, csrfToken); + const response = (await deleteAttachments(attachmentId, csrfToken)) as unknown as Response; if (response.success) { const listItemToDelete = document.getElementById(`attachment-id-${attachmentId}`) as HTMLElement; @@ -143,3 +145,53 @@ const checkForHash = (): void => { submitButton.removeAttribute('disabled'); } }; + +export const handleFaqTranslate = (): void => { + const translateButton = document.getElementById('btn-translate-faq-ai') as HTMLButtonElement | null; + const langSelect = document.getElementById('lang') as HTMLSelectElement | null; + const originalLangInput = document.getElementById('originalFaqLang') as HTMLInputElement | null; + + if (!translateButton || !langSelect || !originalLangInput) { + return; + } + + // Initialize translator when target language is selected + langSelect.addEventListener('change', () => { + const sourceLang = originalLangInput.value; + const targetLang = langSelect.value; + + if (sourceLang && targetLang && sourceLang !== targetLang) { + // Enable the translate button + translateButton.disabled = false; + + // Initialize the Translator + try { + new Translator({ + buttonSelector: '#btn-translate-faq-ai', + contentType: 'faq', + sourceLang: sourceLang, + targetLang: targetLang, + fieldMapping: { + question: '#question', + answer: '#editor', + keywords: '#keywords', + }, + onTranslationSuccess: () => { + pushNotification('Translation completed successfully'); + }, + onTranslationError: (error) => { + pushErrorNotification(`Translation failed: ${error}`); + }, + }); + } catch (error) { + console.error('Failed to initialize translator:', error); + } + } else { + // Disable the translate button if same language or no target language + translateButton.disabled = true; + } + }); + + // Initially disable the button + translateButton.disabled = true; +}; diff --git a/phpmyfaq/admin/assets/src/content/glossary.test.ts b/phpmyfaq/admin/assets/src/content/glossary.test.ts new file mode 100644 index 0000000000..802ef1dea6 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/glossary.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleDeleteGlossary, handleAddGlossary, onOpenUpdateGlossaryModal, handleUpdateGlossary } from './glossary'; +import { createGlossary, deleteGlossary, getGlossary, updateGlossary } from '../api'; +import { pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + }; +}); +vi.mock('bootstrap', () => { + const hideFn = vi.fn(); + return { + Modal: Object.assign( + vi.fn().mockImplementation(() => ({ + show: vi.fn(), + hide: hideFn, + })), + { + getInstance: vi.fn().mockReturnValue({ hide: hideFn }), + } + ), + }; +}); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)); +}; + +describe('Glossary Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleDeleteGlossary', () => { + it('should not throw when no delete buttons exist', () => { + document.body.innerHTML = '
'; + + expect(() => handleDeleteGlossary()).not.toThrow(); + }); + + it('should call deleteGlossary with correct params and remove the row', async () => { + document.body.innerHTML = ` + + + + + + + + +
TermDefinition + +
+ `; + + (deleteGlossary as Mock).mockResolvedValue({ success: 'Glossary item deleted' }); + + handleDeleteGlossary(); + + const button = document.querySelector('.pmf-admin-delete-glossary') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteGlossary).toHaveBeenCalledWith('7', 'en', 'csrf-abc'); + expect(pushNotification).toHaveBeenCalledWith('Glossary item deleted'); + + const row = document.getElementById('glossary-row-1'); + expect(row).toBeNull(); + }); + }); + + describe('handleAddGlossary', () => { + it('should not throw when button is missing', () => { + document.body.innerHTML = '
'; + + expect(() => handleAddGlossary()).not.toThrow(); + }); + + it('should create glossary, close modal, append row, and notify', async () => { + document.body.innerHTML = ` +
+ + + + + + + +
+ `; + + (createGlossary as Mock).mockResolvedValue({ success: 'Glossary item created' }); + + handleAddGlossary(); + + const button = document.getElementById('pmf-admin-glossary-add') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(createGlossary).toHaveBeenCalledWith('en', 'API', 'Application Programming Interface', 'csrf-xyz'); + + // Modal should have been closed + const { Modal } = await import('bootstrap'); + expect(Modal.getInstance).toHaveBeenCalled(); + + // Form fields should be reset + const itemInput = document.getElementById('item') as HTMLInputElement; + const definitionInput = document.getElementById('definition') as HTMLInputElement; + expect(itemInput.value).toBe(''); + expect(definitionInput.value).toBe(''); + + // New row should be appended to the table + const tableBody = document.querySelector('#pmf-admin-glossary-table tbody') as HTMLElement; + const rows = tableBody.querySelectorAll('tr'); + expect(rows.length).toBe(1); + + expect(pushNotification).toHaveBeenCalledWith('Glossary item created'); + }); + }); + + describe('onOpenUpdateGlossaryModal', () => { + it('should not throw when modal element is missing', () => { + document.body.innerHTML = '
'; + + expect(() => onOpenUpdateGlossaryModal()).not.toThrow(); + }); + + it('should fill form fields from API response when modal opens', async () => { + document.body.innerHTML = ` +
+ + + + + + `; + + (getGlossary as Mock).mockResolvedValue({ item: 'Glossar', definition: 'Eine Sammlung von Begriffen' }); + + onOpenUpdateGlossaryModal(); + + const modal = document.getElementById('updateGlossaryModal') as HTMLElement; + const triggerButton = document.getElementById('trigger-btn') as HTMLElement; + + // Dispatch the show.bs.modal event with relatedTarget + const event = new Event('show.bs.modal'); + Object.defineProperty(event, 'relatedTarget', { value: triggerButton }); + modal.dispatchEvent(event); + + await flushPromises(); + + expect(getGlossary).toHaveBeenCalledWith('5', 'de'); + + const updateId = document.getElementById('update-id') as HTMLInputElement; + const updateLang = document.getElementById('update-language') as HTMLInputElement; + const updateItem = document.getElementById('update-item') as HTMLInputElement; + const updateDef = document.getElementById('update-definition') as HTMLInputElement; + + expect(updateId.value).toBe('5'); + expect(updateLang.value).toBe('de'); + expect(updateItem.value).toBe('Glossar'); + expect(updateDef.value).toBe('Eine Sammlung von Begriffen'); + }); + }); + + describe('handleUpdateGlossary', () => { + it('should not throw when button is missing', () => { + document.body.innerHTML = '
'; + + expect(() => handleUpdateGlossary()).not.toThrow(); + }); + + it('should update glossary, close modal, update DOM cells, and notify', async () => { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + + +
Old TermOld DefinitionActions
+ `; + + (updateGlossary as Mock).mockResolvedValue({ success: 'Glossary item updated' }); + + handleUpdateGlossary(); + + const button = document.getElementById('pmf-admin-glossary-update') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(updateGlossary).toHaveBeenCalledWith('3', 'en', 'Updated Term', 'Updated Definition', 'csrf-update'); + + // Modal should have been closed + const { Modal } = await import('bootstrap'); + expect(Modal.getInstance).toHaveBeenCalled(); + + // DOM cells should be updated + const itemLink = document.querySelector('#pmf-glossary-id-3 td:nth-child(1) a') as HTMLElement; + const definitionCell = document.querySelector('#pmf-glossary-id-3 td:nth-child(2)') as HTMLElement; + expect(itemLink.innerText).toBe('Updated Term'); + expect(definitionCell.innerText).toBe('Updated Definition'); + + expect(pushNotification).toHaveBeenCalledWith('Glossary item updated'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/glossary.ts b/phpmyfaq/admin/assets/src/content/glossary.ts index 918bd56f42..b8e56f0eac 100644 --- a/phpmyfaq/admin/assets/src/content/glossary.ts +++ b/phpmyfaq/admin/assets/src/content/glossary.ts @@ -16,6 +16,7 @@ import { createGlossary, deleteGlossary, getGlossary, updateGlossary } from '../api'; import { addElement, pushNotification } from '../../../../assets/src/utils'; import { Modal } from 'bootstrap'; +import { GlossaryResponse, Response } from '../interfaces'; export const handleDeleteGlossary = (): void => { const deleteButtons: NodeListOf = document.querySelectorAll('.pmf-admin-delete-glossary'); @@ -29,7 +30,7 @@ export const handleDeleteGlossary = (): void => { const csrfToken = button.getAttribute('data-pmf-csrf-token') as string; const glossaryLang = button.getAttribute('data-pmf-glossary-language') as string; - const response = await deleteGlossary(glossaryId, glossaryLang, csrfToken); + const response = (await deleteGlossary(glossaryId, glossaryLang, csrfToken)) as Response | undefined; if (response) { (button.closest('tr') as HTMLElement).remove(); @@ -53,7 +54,9 @@ export const handleAddGlossary = (): void => { const glossaryDefinition = (document.getElementById('definition') as HTMLInputElement).value as string; const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement).value as string; - const response = await createGlossary(glossaryLanguage, glossaryItem, glossaryDefinition, csrfToken); + const response = (await createGlossary(glossaryLanguage, glossaryItem, glossaryDefinition, csrfToken)) as + | Response + | undefined; if (response) { if (modal) { @@ -118,7 +121,7 @@ export const onOpenUpdateGlossaryModal = (): void => { (document.getElementById('update-language') as HTMLInputElement).value = glossaryLang; try { - const response = await getGlossary(glossaryId, glossaryLang); + const response = (await getGlossary(glossaryId, glossaryLang)) as GlossaryResponse | undefined; (document.getElementById('update-item') as HTMLInputElement).value = response?.item ?? ''; (document.getElementById('update-definition') as HTMLInputElement).value = response?.definition ?? ''; @@ -143,7 +146,13 @@ export const handleUpdateGlossary = (): void => { const glossaryDefinition = (document.getElementById('update-definition') as HTMLInputElement).value as string; const csrfToken = (document.getElementById('update-csrf-token') as HTMLInputElement).value as string; - const response = await updateGlossary(glossaryId, glossaryLanguage, glossaryItem, glossaryDefinition, csrfToken); + const response = (await updateGlossary( + glossaryId, + glossaryLanguage, + glossaryItem, + glossaryDefinition, + csrfToken + )) as Response | undefined; if (response) { if (modal) { diff --git a/phpmyfaq/admin/assets/src/content/index.ts b/phpmyfaq/admin/assets/src/content/index.ts index 05301f7e72..9bbd979215 100644 --- a/phpmyfaq/admin/assets/src/content/index.ts +++ b/phpmyfaq/admin/assets/src/content/index.ts @@ -12,6 +12,7 @@ export * from './markdown'; export * from './media.browser'; export * from './news'; export * from './faqs.overview'; +export * from './pages'; export * from './question'; export * from './tags'; export * from './stickyfaqs'; diff --git a/phpmyfaq/admin/assets/src/content/markdown.test.ts b/phpmyfaq/admin/assets/src/content/markdown.test.ts new file mode 100644 index 0000000000..f1f3e5ad3e --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/markdown.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleMarkdownForm } from './markdown'; + +vi.mock('bootstrap', () => { + const ModalMock = class { + show = vi.fn(); + hide = vi.fn(); + }; + return { Modal: ModalMock }; +}); + +vi.mock('../api', () => ({ + fetchMarkdownContent: vi.fn(), + fetchMediaBrowserContent: vi.fn(), +})); + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +describe('handleMarkdownForm', () => { + let mockLocalStorage: Record; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + + mockLocalStorage = {}; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => { + return mockLocalStorage[key] ?? null; + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => { + mockLocalStorage[key] = value; + }); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should not throw when answer element does not exist', () => { + document.body.innerHTML = '
'; + + expect(() => handleMarkdownForm()).not.toThrow(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should restore textarea height from localStorage', () => { + mockLocalStorage['phpmyfaq.answer.height'] = '300px'; + + document.body.innerHTML = ''; + + handleMarkdownForm(); + + const answer = document.getElementById('answer-markdown') as HTMLTextAreaElement; + expect(answer.style.height).toBe('300px'); + }); + + it('should save textarea height on mouseup', () => { + document.body.innerHTML = ''; + + handleMarkdownForm(); + + const answer = document.getElementById('answer-markdown') as HTMLTextAreaElement; + answer.style.height = '450px'; + answer.dispatchEvent(new Event('mouseup')); + + expect(localStorage.setItem).toHaveBeenCalledWith('phpmyfaq.answer.height', '450px'); + }); + + it('should handle missing markdownTabs gracefully', () => { + document.body.innerHTML = ''; + + expect(() => handleMarkdownForm()).not.toThrow(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should handle missing insertImage gracefully', () => { + document.body.innerHTML = ` + +
+ `; + + expect(() => handleMarkdownForm()).not.toThrow(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should handle missing imageUpload gracefully', () => { + document.body.innerHTML = ` + +
+
+
+
+ `; + + expect(() => handleMarkdownForm()).not.toThrow(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/media.browser.test.ts b/phpmyfaq/admin/assets/src/content/media.browser.test.ts new file mode 100644 index 0000000000..99f9270921 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/media.browser.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleFileFilter } from './media.browser'; + +describe('handleFileFilter', () => { + let postMessageMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + + postMessageMock = vi.fn(); + Object.defineProperty(window, 'parent', { + value: { postMessage: postMessageMock }, + writable: true, + }); + }); + + describe('filter input behavior', () => { + it('should not throw when filter input does not exist', () => { + document.body.innerHTML = ` +
image.png
+ `; + + expect(() => handleFileFilter()).not.toThrow(); + }); + + it('should filter file divs based on input text', () => { + document.body.innerHTML = ` + +
photo.png
+
document.pdf
+
archive.zip
+ `; + + handleFileFilter(); + + const filterInput = document.getElementById('filter') as HTMLInputElement; + filterInput.value = 'photo'; + filterInput.dispatchEvent(new Event('keyup')); + + const fileDivs = document.querySelectorAll('div.mce-file') as NodeListOf; + expect(fileDivs[0].style.display).toBe('block'); + expect(fileDivs[1].style.display).toBe('none'); + expect(fileDivs[2].style.display).toBe('none'); + }); + + it('should show all file divs when filter input is empty', () => { + document.body.innerHTML = ` + +
photo.png
+
document.pdf
+ `; + + handleFileFilter(); + + const filterInput = document.getElementById('filter') as HTMLInputElement; + filterInput.value = ''; + filterInput.dispatchEvent(new Event('keyup')); + + const fileDivs = document.querySelectorAll('div.mce-file') as NodeListOf; + expect(fileDivs[0].style.display).toBe('block'); + expect(fileDivs[1].style.display).toBe('block'); + }); + + it('should filter with case-insensitive matching', () => { + document.body.innerHTML = ` + +
Photo.PNG
+
DOCUMENT.pdf
+
archive.ZIP
+ `; + + handleFileFilter(); + + const filterInput = document.getElementById('filter') as HTMLInputElement; + filterInput.value = 'photo'; + filterInput.dispatchEvent(new Event('keyup')); + + const fileDivs = document.querySelectorAll('div.mce-file') as NodeListOf; + expect(fileDivs[0].style.display).toBe('block'); + expect(fileDivs[1].style.display).toBe('none'); + expect(fileDivs[2].style.display).toBe('none'); + }); + + it('should handle case-insensitive filter with uppercase input', () => { + document.body.innerHTML = ` + +
photo.png
+
document.pdf
+ `; + + handleFileFilter(); + + const filterInput = document.getElementById('filter') as HTMLInputElement; + filterInput.value = 'PHOTO'; + filterInput.dispatchEvent(new Event('keyup')); + + const fileDivs = document.querySelectorAll('div.mce-file') as NodeListOf; + expect(fileDivs[0].style.display).toBe('block'); + expect(fileDivs[1].style.display).toBe('none'); + }); + }); + + describe('click behavior for file selection', () => { + it('should post message to parent when a .mce-file div with data-src is clicked', () => { + document.body.innerHTML = ` + +
image.png
+ `; + + handleFileFilter(); + + const fileDiv = document.querySelector('div.mce-file') as HTMLElement; + fileDiv.click(); + + expect(postMessageMock).toHaveBeenCalledWith( + { + mceAction: 'phpMyFAQMediaBrowserAction', + url: '/uploads/image.png', + }, + '*' + ); + }); + + it('should not post message when a .mce-file div without data-src is clicked', () => { + document.body.innerHTML = ` + +
image.png
+ `; + + handleFileFilter(); + + const fileDiv = document.querySelector('div.mce-file') as HTMLElement; + fileDiv.click(); + + expect(postMessageMock).not.toHaveBeenCalled(); + }); + + it('should not post message when a non-.mce-file element is clicked', () => { + document.body.innerHTML = ` + +
image.png
+
other
+ `; + + handleFileFilter(); + + const otherDiv = document.querySelector('.other-element') as HTMLElement; + otherDiv.click(); + + expect(postMessageMock).not.toHaveBeenCalled(); + }); + + it('should post message with correct src for different file divs', () => { + document.body.innerHTML = ` + +
first.png
+
second.jpg
+ `; + + handleFileFilter(); + + const secondFileDiv = document.querySelectorAll('div.mce-file')[1] as HTMLElement; + secondFileDiv.click(); + + expect(postMessageMock).toHaveBeenCalledWith( + { + mceAction: 'phpMyFAQMediaBrowserAction', + url: '/uploads/second.jpg', + }, + '*' + ); + }); + + it('should still register click listener even when filter input does not exist', () => { + document.body.innerHTML = ` +
image.png
+ `; + + handleFileFilter(); + + const fileDiv = document.querySelector('div.mce-file') as HTMLElement; + fileDiv.click(); + + expect(postMessageMock).toHaveBeenCalledWith( + { + mceAction: 'phpMyFAQMediaBrowserAction', + url: '/uploads/image.png', + }, + '*' + ); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/news.test.ts b/phpmyfaq/admin/assets/src/content/news.test.ts new file mode 100644 index 0000000000..1fd999b7e1 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/news.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleAddNews, handleEditNews, handleNews } from './news'; +import { addNews, deleteNews, activateNews, updateNews } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils'); +vi.mock('bootstrap', () => ({ + Modal: vi.fn().mockImplementation(() => ({ + show: vi.fn(), + hide: vi.fn(), + })), +})); + +const setupAddNewsDom = (): void => { + document.body.innerHTML = ` + + + + + + + + + + + + + `; +}; + +const setupEditNewsDom = (): void => { + document.body.innerHTML = ` + + + + + + + + + + + + + + `; +}; + +const setupNewsListDom = (): void => { + document.body.innerHTML = ` + +
+ + + + + `; +}; + +describe('News Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleAddNews', () => { + it('should do nothing when submit button is missing', () => { + document.body.innerHTML = '
'; + + handleAddNews(); + + expect(addNews).not.toHaveBeenCalled(); + }); + + it('should call addNews with form data and show success notification', async () => { + setupAddNewsDom(); + + (addNews as Mock).mockResolvedValue({ success: 'News added successfully' }); + + handleAddNews(); + + const button = document.getElementById('submitAddNews') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(addNews).toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('News added successfully'); + }); + + it('should show error notification on failure', async () => { + setupAddNewsDom(); + + (addNews as Mock).mockResolvedValue({ error: 'Failed to add news' }); + + handleAddNews(); + + const button = document.getElementById('submitAddNews') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Failed to add news'); + }); + + it('should show default error when no error message provided', async () => { + setupAddNewsDom(); + + (addNews as Mock).mockResolvedValue({}); + + handleAddNews(); + + const button = document.getElementById('submitAddNews') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('An error occurred'); + }); + }); + + describe('handleEditNews', () => { + it('should do nothing when submit button is missing', () => { + document.body.innerHTML = '
'; + + handleEditNews(); + + expect(updateNews).not.toHaveBeenCalled(); + }); + + it('should call updateNews with form data and show success notification', async () => { + setupEditNewsDom(); + + (updateNews as Mock).mockResolvedValue({ success: 'News updated successfully' }); + + handleEditNews(); + + const button = document.getElementById('submitEditNews') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(updateNews).toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('News updated successfully'); + }); + + it('should show error notification on failure', async () => { + setupEditNewsDom(); + + (updateNews as Mock).mockResolvedValue({ error: 'Update failed' }); + + handleEditNews(); + + const button = document.getElementById('submitEditNews') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Update failed'); + }); + }); + + describe('handleNews', () => { + it('should do nothing when deleteNews button is missing', () => { + document.body.innerHTML = '
'; + + handleNews(); + + expect(deleteNews).not.toHaveBeenCalled(); + }); + + it('should delete news on confirm and show success notification', async () => { + setupNewsListDom(); + + (deleteNews as Mock).mockResolvedValue({ success: 'News deleted' }); + + handleNews(); + + // Click the delete action button directly (simulating modal confirm) + const deleteAction = document.getElementById('pmf-delete-news-action') as HTMLButtonElement; + // First set the newsId as the modal would + (document.getElementById('newsId') as HTMLInputElement).value = '42'; + deleteAction.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteNews).toHaveBeenCalledWith('delete-csrf', '42'); + expect(pushNotification).toHaveBeenCalledWith('News deleted'); + }); + + it('should show error notification when delete fails', async () => { + setupNewsListDom(); + + (deleteNews as Mock).mockResolvedValue({ error: 'Delete failed' }); + + handleNews(); + + (document.getElementById('newsId') as HTMLInputElement).value = '42'; + const deleteAction = document.getElementById('pmf-delete-news-action') as HTMLButtonElement; + deleteAction.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Delete failed'); + }); + + it('should activate news and show success notification', async () => { + setupNewsListDom(); + + (activateNews as Mock).mockResolvedValue({ success: 'News activated' }); + + handleNews(); + + const checkbox = document.getElementById('activate') as HTMLInputElement; + checkbox.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(activateNews).toHaveBeenCalledWith('42', String(checkbox.checked), 'activate-csrf'); + expect(pushNotification).toHaveBeenCalledWith('News activated'); + }); + + it('should show error notification when activation fails', async () => { + setupNewsListDom(); + + (activateNews as Mock).mockResolvedValue({ error: 'Activation failed' }); + + handleNews(); + + const checkbox = document.getElementById('activate') as HTMLInputElement; + checkbox.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Activation failed'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/news.ts b/phpmyfaq/admin/assets/src/content/news.ts index 70e4d3620d..3191a182b9 100644 --- a/phpmyfaq/admin/assets/src/content/news.ts +++ b/phpmyfaq/admin/assets/src/content/news.ts @@ -59,14 +59,14 @@ export const handleAddNews = (): void => { target: target, csrfToken: (document.getElementById('pmf-csrf-token') as HTMLInputElement).value, }; - const response = (await addNews(data)) as unknown as Response; + const response = (await addNews(data as unknown as Record)) as unknown as Response; if (typeof response.success === 'string') { pushNotification(response.success); setTimeout(() => { window.location.href = './news'; }, 3000); } else { - pushErrorNotification(response.error); + pushErrorNotification(response.error ?? 'An error occurred'); } }); } @@ -96,22 +96,22 @@ export const handleNews = (): void => { window.location.reload(); }, 3000); } else { - pushErrorNotification(response.error); + pushErrorNotification(response.error ?? 'An error occurred'); } } ); document.querySelectorAll('#activate').forEach((item) => { item.addEventListener('click', async () => { - const response = await activateNews( + const response = (await activateNews( item.getAttribute('data-pmf-id') as string, - item.checked, + String(item.checked), item.getAttribute('data-pmf-csrf-token') as string - ); + )) as unknown as Response; if (typeof response.success === 'string') { pushNotification(response.success); } else { - pushErrorNotification(response.error); + pushErrorNotification(response.error ?? 'An error occurred'); } }); }); @@ -145,14 +145,14 @@ export const handleEditNews = (): void => { target: target, }; - const reponse = (await updateNews(data)) as unknown as Response; + const reponse = (await updateNews(data as unknown as Record)) as unknown as Response; if (typeof reponse.success === 'string') { pushNotification(reponse.success); setTimeout(() => { window.location.href = './news'; }, 3000); } else { - pushErrorNotification(reponse.error); + pushErrorNotification(reponse.error ?? 'An error occurred'); } }); } diff --git a/phpmyfaq/admin/assets/src/content/pages.test.ts b/phpmyfaq/admin/assets/src/content/pages.test.ts new file mode 100644 index 0000000000..5fbad7d493 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/pages.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleAddPage, handleEditPage, handlePages, handleTranslatePage } from './pages'; +import { addPage, deletePage, updatePage, activatePage } from '../api'; +import { pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('./editor', () => ({ + renderPageEditor: vi.fn(), +})); +vi.mock('../translation/translator', () => ({ + Translator: vi.fn(), +})); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); +vi.mock('bootstrap', () => { + const ModalMock = vi.fn(); + ModalMock.prototype.show = vi.fn(); + ModalMock.prototype.hide = vi.fn(); + return { Modal: ModalMock }; +}); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)); +}; + +const setupAddPageDom = (): void => { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + 0 + 0 +
+ `; +}; + +const setupEditPageDom = (): void => { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + + 0 + 0 +
+ `; +}; + +const setupPagesListDom = (): void => { + document.body.innerHTML = ` + +
+ + + + + + `; +}; + +const setupTranslatePageDom = (): void => { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + + + + 0 + 0 +
+ `; +}; + +describe('Pages Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleAddPage', () => { + it('should do nothing when form is missing', () => { + document.body.innerHTML = '
'; + + handleAddPage(); + + expect(addPage).not.toHaveBeenCalled(); + }); + + it('should auto-generate slug from title input', () => { + setupAddPageDom(); + + handleAddPage(); + + const titleInput = document.getElementById('pageTitle') as HTMLInputElement; + const slugInput = document.getElementById('slug') as HTMLInputElement; + + titleInput.value = 'My New Page Title'; + titleInput.dispatchEvent(new Event('input')); + + expect(slugInput.value).toBe('my-new-page-title'); + expect(slugInput.dataset.autoGenerated).toBe('true'); + }); + + it('should stop auto-generating when user manually edits slug', () => { + setupAddPageDom(); + + handleAddPage(); + + const titleInput = document.getElementById('pageTitle') as HTMLInputElement; + const slugInput = document.getElementById('slug') as HTMLInputElement; + + // First auto-generate + titleInput.value = 'Initial Title'; + titleInput.dispatchEvent(new Event('input')); + expect(slugInput.value).toBe('initial-title'); + + // User manually edits slug + slugInput.value = 'custom-slug'; + slugInput.dispatchEvent(new Event('input')); + expect(slugInput.dataset.autoGenerated).toBe('false'); + + // Typing more in title should not overwrite the manual slug + titleInput.value = 'Updated Title'; + titleInput.dispatchEvent(new Event('input')); + expect(slugInput.value).toBe('custom-slug'); + }); + + it('should call addPage and show notification on success', async () => { + setupAddPageDom(); + + (addPage as Mock).mockResolvedValue({ success: 'Page added successfully' }); + + handleAddPage(); + + const button = document.getElementById('pmf-submit-page') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(addPage).toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('Page added successfully'); + }); + }); + + describe('handleEditPage', () => { + it('should do nothing when form is missing', () => { + document.body.innerHTML = '
'; + + handleEditPage(); + + expect(updatePage).not.toHaveBeenCalled(); + }); + + it('should call updatePage with form data on submission', async () => { + setupEditPageDom(); + + (updatePage as Mock).mockResolvedValue({ success: 'Page updated successfully' }); + + handleEditPage(); + + const button = document.getElementById('pmf-submit-page') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(updatePage).toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('Page updated successfully'); + }); + }); + + describe('handlePages', () => { + it('should do nothing when no delete buttons exist', () => { + document.body.innerHTML = '
'; + + handlePages(); + + expect(deletePage).not.toHaveBeenCalled(); + expect(activatePage).not.toHaveBeenCalled(); + }); + + it('should call activatePage when checkbox is clicked', async () => { + setupPagesListDom(); + + (activatePage as Mock).mockResolvedValue({ success: 'Page activated' }); + + handlePages(); + + const checkbox = document.getElementById('activate') as HTMLInputElement; + checkbox.click(); + + await flushPromises(); + + expect(activatePage).toHaveBeenCalledWith('42', checkbox.checked, 'activate-csrf'); + expect(pushNotification).toHaveBeenCalledWith('Page activated'); + }); + + it('should open modal when delete button is clicked', () => { + setupPagesListDom(); + + handlePages(); + + const deleteButton = document.getElementById('deletePage') as HTMLButtonElement; + deleteButton.click(); + + const pageIdInput = document.getElementById('pageId') as HTMLInputElement; + const pageLangInput = document.getElementById('pageLang') as HTMLInputElement; + + expect(pageIdInput.value).toBe('42'); + expect(pageLangInput.value).toBe('en'); + }); + }); + + describe('handleTranslatePage', () => { + it('should do nothing when form is missing', () => { + document.body.innerHTML = '
'; + + handleTranslatePage(); + + expect(addPage).not.toHaveBeenCalled(); + }); + + it('should call addPage on form submission', async () => { + setupTranslatePageDom(); + + (addPage as Mock).mockResolvedValue({ success: 'Translation added successfully' }); + + handleTranslatePage(); + + const button = document.getElementById('pmf-submit-page') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(addPage).toHaveBeenCalled(); + expect(pushNotification).toHaveBeenCalledWith('Translation added successfully'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/pages.ts b/phpmyfaq/admin/assets/src/content/pages.ts new file mode 100644 index 0000000000..9d75bcc80a --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/pages.ts @@ -0,0 +1,452 @@ +/** + * Custom Pages administration + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-15 + */ + +import { activatePage, addPage, checkSlug, deletePage, updatePage } from '../api'; +import { Modal } from 'bootstrap'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; +import { Response } from '../interfaces'; +import { renderPageEditor } from './editor'; +import { Translator } from '../translation/translator'; + +interface PageData { + pageTitle: string; + slug: string; + content: string; + authorName: string; + authorEmail: string; + active: boolean; + lang: string; + csrfToken: string; + id?: string; + [key: string]: unknown; +} + +/** + * Generates a URL-friendly slug from a title + */ +const generateSlug = (title: string): string => { + return title + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +}; + +/** + * Debounce function for slug validation + */ +const debounce = void>( + func: T, + wait: number +): ((...args: Parameters) => void) => { + let timeout: ReturnType; + return (...args: Parameters): void => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +}; + +/** + * Updates character counter for SEO fields + */ +const updateCharCounter = (inputId: string, counterId: string, maxLength: number): void => { + const input = document.getElementById(inputId) as HTMLInputElement | HTMLTextAreaElement | null; + const counter = document.getElementById(counterId) as HTMLElement | null; + + if (input && counter) { + const updateCount = () => { + const length = input.value.length; + counter.textContent = length.toString(); + + if (length > maxLength) { + counter.classList.add('text-danger'); + counter.classList.remove('text-warning'); + } else if (length > maxLength * 0.9) { + counter.classList.add('text-warning'); + counter.classList.remove('text-danger'); + } else { + counter.classList.remove('text-danger', 'text-warning'); + } + }; + + input.addEventListener('input', updateCount); + updateCount(); + } +}; + +/** + * Validates slug availability + */ +const validateSlug = async (slug: string, lang: string, csrfToken: string, excludeId?: string): Promise => { + const response = (await checkSlug(slug, lang, csrfToken, excludeId)) as { available: boolean }; + return response.available; +}; + +/** + * Handle add page form + */ +export const handleAddPage = (): void => { + const form = document.getElementById('pmf-add-page-form') as HTMLFormElement | null; + if (!form) return; + + // Initialize WYSIWYG editor for content field + renderPageEditor(); + + const titleInput = document.getElementById('pageTitle') as HTMLInputElement | null; + const slugInput = document.getElementById('slug') as HTMLInputElement | null; + const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value; + const langInput = document.getElementById('lang') as HTMLSelectElement | null; + + // Auto-generate slug from title + if (titleInput && slugInput) { + titleInput.addEventListener('input', () => { + if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') { + slugInput.value = generateSlug(titleInput.value); + slugInput.dataset.autoGenerated = 'true'; + } + }); + + slugInput.addEventListener('input', () => { + slugInput.dataset.autoGenerated = 'false'; + }); + } + + // Real-time slug validation + if (slugInput && langInput) { + const validateSlugDebounced = debounce(async () => { + const slug = slugInput.value; + const lang = langInput.value; + const feedback = document.getElementById('slug-feedback') as HTMLElement | null; + + if (!slug || !lang) return; + + const isAvailable = await validateSlug(slug, lang, csrfToken); + + if (isAvailable) { + slugInput.classList.remove('is-invalid'); + slugInput.classList.add('is-valid'); + if (feedback) feedback.textContent = ''; + } else { + slugInput.classList.remove('is-valid'); + slugInput.classList.add('is-invalid'); + if (feedback) feedback.textContent = 'This slug already exists for this language'; + } + }, 500); + + slugInput.addEventListener('input', validateSlugDebounced); + langInput.addEventListener('change', validateSlugDebounced); + } + + // SEO character counters + updateCharCounter('seoTitle', 'seo-title-counter', 60); + updateCharCounter('seoDescription', 'seo-description-counter', 160); + + // Form submission + const submitButton = document.getElementById('pmf-submit-page') as HTMLButtonElement | null; + if (submitButton) { + submitButton.addEventListener('click', async (event: Event) => { + event.preventDefault(); + + const data: PageData = { + pageTitle: (document.getElementById('pageTitle') as HTMLInputElement).value, + slug: (document.getElementById('slug') as HTMLInputElement).value, + content: (document.getElementById('content') as HTMLTextAreaElement).value, + authorName: (document.getElementById('authorName') as HTMLInputElement).value, + authorEmail: (document.getElementById('authorEmail') as HTMLInputElement).value, + active: (document.getElementById('active') as HTMLInputElement).checked, + lang: (document.getElementById('lang') as HTMLSelectElement).value, + seoTitle: (document.getElementById('seoTitle') as HTMLInputElement).value, + seoDescription: (document.getElementById('seoDescription') as HTMLTextAreaElement).value, + seoRobots: (document.getElementById('seoRobots') as HTMLSelectElement).value, + csrfToken: csrfToken, + }; + + const response = (await addPage(data)) as unknown as Response; + if (typeof response.success === 'string') { + pushNotification(response.success); + setTimeout(() => { + window.location.href = './pages'; + }, 2000); + } else { + pushErrorNotification(response.error || 'An error occurred'); + } + }); + } +}; + +/** + * Handle translate page form + */ +export const handleTranslatePage = (): void => { + const form = document.getElementById('pmf-translate-page-form') as HTMLFormElement | null; + if (!form) return; + + // Initialize WYSIWYG editor for the content field + renderPageEditor(); + + const titleInput = document.getElementById('pageTitle') as HTMLInputElement | null; + const slugInput = document.getElementById('slug') as HTMLInputElement | null; + const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value; + const langInput = document.getElementById('lang') as HTMLSelectElement | null; + const pageIdInput = document.getElementById('pageId') as HTMLInputElement | null; + + // Auto-generate slug from title + if (titleInput && slugInput) { + titleInput.addEventListener('input', () => { + if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') { + slugInput.value = generateSlug(titleInput.value); + slugInput.dataset.autoGenerated = 'true'; + } + }); + + slugInput.addEventListener('input', () => { + slugInput.dataset.autoGenerated = 'false'; + }); + } + + // Real-time slug validation + if (slugInput && langInput) { + const validateSlugDebounced = debounce(async () => { + const slug = slugInput.value; + const lang = langInput.value; + const feedback = document.getElementById('slug-feedback') as HTMLElement | null; + + if (!slug || !lang) return; + + const isAvailable = await validateSlug(slug, lang, csrfToken); + + if (isAvailable) { + slugInput.classList.remove('is-invalid'); + slugInput.classList.add('is-valid'); + if (feedback) feedback.textContent = ''; + } else { + slugInput.classList.remove('is-valid'); + slugInput.classList.add('is-invalid'); + if (feedback) feedback.textContent = 'This slug already exists for this language'; + } + }, 500); + + slugInput.addEventListener('input', validateSlugDebounced); + langInput.addEventListener('change', validateSlugDebounced); + } + + // SEO character counters + updateCharCounter('seoTitle', 'seo-title-counter', 60); + updateCharCounter('seoDescription', 'seo-description-counter', 160); + + // AI Translation integration + const translateButton = document.getElementById('btn-translate-page-ai') as HTMLButtonElement | null; + const originalLangInput = document.getElementById('originalLang') as HTMLInputElement | null; + + if (translateButton && langInput && originalLangInput) { + // Initialize translator when the target language is selected + langInput.addEventListener('change', () => { + const sourceLang = originalLangInput.value; + const targetLang = langInput.value; + + if (sourceLang && targetLang && sourceLang !== targetLang) { + // Enable the translate button + translateButton.disabled = false; + + // Initialize the Translator + try { + new Translator({ + buttonSelector: '#btn-translate-page-ai', + contentType: 'customPage', + sourceLang: sourceLang, + targetLang: targetLang, + fieldMapping: { + pageTitle: '#pageTitle', + content: '#content', + seoTitle: '#seoTitle', + seoDescription: '#seoDescription', + }, + onTranslationSuccess: () => { + pushNotification('Translation completed successfully'); + }, + onTranslationError: (error) => { + pushErrorNotification(`Translation failed: ${error}`); + }, + }); + } catch (error) { + console.error('Failed to initialize translator:', error); + } + } else { + // Disable the translation button if the same language or no target language + translateButton.disabled = true; + } + }); + + // Initially disable the button + translateButton.disabled = true; + } + + // Form submission + const submitButton = document.getElementById('pmf-submit-page') as HTMLButtonElement | null; + if (submitButton) { + submitButton.addEventListener('click', async (event: Event) => { + event.preventDefault(); + + const data: PageData = { + pageId: pageIdInput?.value, // Include pageId for translation + pageTitle: (document.getElementById('pageTitle') as HTMLInputElement).value, + slug: (document.getElementById('slug') as HTMLInputElement).value, + content: (document.getElementById('content') as HTMLTextAreaElement).value, + authorName: (document.getElementById('authorName') as HTMLInputElement).value, + authorEmail: (document.getElementById('authorEmail') as HTMLInputElement).value, + active: (document.getElementById('active') as HTMLInputElement).checked, + lang: (document.getElementById('lang') as HTMLSelectElement).value, + seoTitle: (document.getElementById('seoTitle') as HTMLInputElement).value, + seoDescription: (document.getElementById('seoDescription') as HTMLTextAreaElement).value, + seoRobots: (document.getElementById('seoRobots') as HTMLSelectElement).value, + csrfToken: csrfToken, + }; + + const response = (await addPage(data)) as unknown as Response; + pushNotification(response.success); + setTimeout(() => { + window.location.href = './pages'; + }, 2000); + }); + } +}; + +/** + * Handle edit page form + */ +export const handleEditPage = (): void => { + const form = document.getElementById('pmf-edit-page-form') as HTMLFormElement | null; + if (!form) return; + + // Initialize WYSIWYG editor for the content field + renderPageEditor(); + + const slugInput = document.getElementById('slug') as HTMLInputElement | null; + const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value; + const langInput = document.getElementById('lang') as HTMLInputElement | null; + const pageIdInput = document.getElementById('pageId') as HTMLInputElement | null; + + // Real-time slug validation (excluding current page) + if (slugInput && langInput && pageIdInput) { + const validateSlugDebounced = debounce(async () => { + const slug = slugInput.value; + const lang = langInput.value; + const pageId = pageIdInput.value; + const feedback = document.getElementById('slug-feedback') as HTMLElement | null; + + if (!slug || !lang) return; + + const isAvailable = await validateSlug(slug, lang, csrfToken, pageId); + + if (isAvailable) { + slugInput.classList.remove('is-invalid'); + slugInput.classList.add('is-valid'); + if (feedback) feedback.textContent = ''; + } else { + slugInput.classList.remove('is-valid'); + slugInput.classList.add('is-invalid'); + if (feedback) feedback.textContent = 'This slug already exists for this language'; + } + }, 500); + + slugInput.addEventListener('input', validateSlugDebounced); + } + + // SEO character counters + updateCharCounter('seoTitle', 'seo-title-counter', 60); + updateCharCounter('seoDescription', 'seo-description-counter', 160); + + // Form submission + const submitButton = document.getElementById('pmf-submit-page') as HTMLButtonElement | null; + if (submitButton) { + submitButton.addEventListener('click', async (event: Event) => { + event.preventDefault(); + + const data: PageData = { + id: (document.getElementById('pageId') as HTMLInputElement).value, + pageTitle: (document.getElementById('pageTitle') as HTMLInputElement).value, + slug: (document.getElementById('slug') as HTMLInputElement).value, + content: (document.getElementById('content') as HTMLTextAreaElement).value, + authorName: (document.getElementById('authorName') as HTMLInputElement).value, + authorEmail: (document.getElementById('authorEmail') as HTMLInputElement).value, + active: (document.getElementById('active') as HTMLInputElement).checked, + lang: (document.getElementById('lang') as HTMLInputElement).value, + seoTitle: (document.getElementById('seoTitle') as HTMLInputElement).value, + seoDescription: (document.getElementById('seoDescription') as HTMLTextAreaElement).value, + seoRobots: (document.getElementById('seoRobots') as HTMLSelectElement).value, + csrfToken: csrfToken, + }; + + const response = (await updatePage(data)) as unknown as Response; + pushNotification(response.success); + setTimeout(() => { + window.location.href = './pages'; + }, 2000); + }); + } +}; + +/** + * Handle pages list (delete and activate) + */ +export const handlePages = (): void => { + // Delete page functionality + const deleteButtons = document.querySelectorAll('#deletePage'); + if (deleteButtons.length > 0) { + deleteButtons.forEach((button) => { + button.addEventListener('click', (event: Event) => { + event.preventDefault(); + const modal = new Modal(document.getElementById('confirmDeletePageModal') as HTMLElement); + const pageId = button.getAttribute('data-pmf-pageid') as string; + const pageLang = button.getAttribute('data-pmf-lang') as string; + + (document.getElementById('pageId') as HTMLInputElement).value = pageId; + (document.getElementById('pageLang') as HTMLInputElement).value = pageLang; + modal.show(); + }); + }); + + const deleteAction = document.getElementById('pmf-delete-page-action') as HTMLButtonElement | null; + if (deleteAction) { + deleteAction.addEventListener('click', async (event: Event) => { + event.preventDefault(); + const csrfToken = (document.getElementById('pmf-csrf-token-delete') as HTMLInputElement).value; + const pageId = (document.getElementById('pageId') as HTMLInputElement).value; + const pageLang = (document.getElementById('pageLang') as HTMLInputElement).value; + + const response = (await deletePage(csrfToken, pageId, pageLang)) as unknown as Response; + pushNotification(response.success); + setTimeout(() => { + window.location.reload(); + }, 2000); + }); + } + } + + // Activate/deactivate functionality + const activateCheckboxes = document.querySelectorAll('#activate'); + activateCheckboxes.forEach((checkbox) => { + checkbox.addEventListener('click', async () => { + const pageId = checkbox.getAttribute('data-pmf-id') as string; + const csrfToken = checkbox.getAttribute('data-pmf-csrf-token') as string; + const status = checkbox.checked; + + const response = (await activatePage(pageId, status, csrfToken)) as unknown as Response; + + pushNotification(response.success); + }); + }); +}; diff --git a/phpmyfaq/admin/assets/src/content/question.test.ts b/phpmyfaq/admin/assets/src/content/question.test.ts new file mode 100644 index 0000000000..be32650bad --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/question.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleOpenQuestions, handleToggleVisibility } from './question'; +import { toggleQuestionVisibility } from '../api'; +import { pushErrorNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushErrorNotification: vi.fn(), + pushNotification: vi.fn(), + }; +}); + +describe('Question Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + describe('handleOpenQuestions', () => { + it('should do nothing when delete button is missing', () => { + document.body.innerHTML = '
'; + + handleOpenQuestions(); + + // No error should be thrown and no fetch should be called + expect(document.body.innerHTML).toBe('
'); + }); + + it('should successfully delete checked questions and remove their rows', async () => { + document.body.innerHTML = ` +
+
+ + + + + + + + + + + + + + + +
Question 1
Question 2
Question 3
+
+ + `; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: 'Questions deleted successfully' }), + } as Response) + ); + + handleOpenQuestions(); + + const deleteButton = document.getElementById('pmf-delete-questions') as HTMLButtonElement; + deleteButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(global.fetch).toHaveBeenCalledWith('./api/question/delete', { + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: expect.any(String), + }); + + const responseMessage = document.getElementById('returnMessage') as HTMLElement; + const successAlert = responseMessage.querySelector('div') as HTMLElement; + expect(successAlert).not.toBeNull(); + expect(successAlert.innerText).toBe('Questions deleted successfully'); + + // Checked rows should be removed (tr containing checked inputs) + const rows = document.querySelectorAll('tr'); + expect(rows.length).toBe(1); + expect(rows[0].innerHTML).toContain('Question 3'); + }); + + it('should show error alert on error response', async () => { + document.body.innerHTML = ` +
+
+ +
+ + `; + + const errorMessage = 'Deletion failed'; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve(errorMessage), + } as unknown as Response) + ); + + handleOpenQuestions(); + + const deleteButton = document.getElementById('pmf-delete-questions') as HTMLButtonElement; + deleteButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responseMessageEl = document.getElementById('returnMessage') as HTMLElement; + const errorAlert = responseMessageEl.querySelector('div') as HTMLElement; + expect(errorAlert).not.toBeNull(); + expect(errorAlert.innerText).toBe(errorMessage); + }); + }); + + describe('handleToggleVisibility', () => { + it('should do nothing when no toggle elements exist', () => { + document.body.innerHTML = '
'; + + handleToggleVisibility(); + + expect(toggleQuestionVisibility).not.toHaveBeenCalled(); + }); + + it('should call toggleQuestionVisibility with correct params on click', async () => { + document.body.innerHTML = ` + Toggle + `; + + (toggleQuestionVisibility as Mock).mockResolvedValue({ success: 'Visibility updated' }); + + handleToggleVisibility(); + + const element = document.querySelector('.pmf-toggle-visibility') as HTMLElement; + element.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(toggleQuestionVisibility).toHaveBeenCalledWith('42', true, 'csrf-token-abc'); + }); + + it('should convert visibility string "false" to boolean false', async () => { + document.body.innerHTML = ` + Toggle + `; + + (toggleQuestionVisibility as Mock).mockResolvedValue({ success: 'Visibility updated' }); + + handleToggleVisibility(); + + const element = document.querySelector('.pmf-toggle-visibility') as HTMLElement; + element.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(toggleQuestionVisibility).toHaveBeenCalledWith('7', false, 'csrf-token-xyz'); + }); + + it('should show success text on element when API returns success', async () => { + document.body.innerHTML = ` + Toggle + `; + + (toggleQuestionVisibility as Mock).mockResolvedValue({ success: 'Visibility toggled successfully' }); + + handleToggleVisibility(); + + const element = document.querySelector('.pmf-toggle-visibility') as HTMLElement; + element.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(element.innerText).toBe('Visibility toggled successfully'); + }); + + it('should show error notification when API returns error', async () => { + document.body.innerHTML = ` + Toggle + `; + + (toggleQuestionVisibility as Mock).mockResolvedValue({ error: 'Toggle failed' }); + + handleToggleVisibility(); + + const element = document.querySelector('.pmf-toggle-visibility') as HTMLElement; + element.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('Toggle failed'); + }); + + it('should show default error notification when API returns undefined', async () => { + document.body.innerHTML = ` + Toggle + `; + + (toggleQuestionVisibility as Mock).mockResolvedValue(undefined); + + handleToggleVisibility(); + + const element = document.querySelector('.pmf-toggle-visibility') as HTMLElement; + element.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pushErrorNotification).toHaveBeenCalledWith('An error occurred'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/question.ts b/phpmyfaq/admin/assets/src/content/question.ts index 266bbf6f90..0e56bf01ba 100644 --- a/phpmyfaq/admin/assets/src/content/question.ts +++ b/phpmyfaq/admin/assets/src/content/question.ts @@ -51,7 +51,7 @@ export const handleOpenQuestions = (): void => { ); const questionsToDelete = document.querySelectorAll('tr td input:checked') as NodeListOf; questionsToDelete.forEach((toDelete) => { - toDelete.parentNode?.parentNode?.parentNode?.remove(); + (toDelete.parentNode?.parentNode?.parentNode as HTMLElement | null)?.remove(); }); } else { responseMessage.append(addElement('div', { classList: 'alert alert-danger', innerText: response.error })); @@ -77,15 +77,13 @@ export const handleToggleVisibility = (): void => { const visibility = element.getAttribute('data-pmf-visibility') as string; const csrfToken = element.getAttribute('data-pmf-csrf') as string; - const response = await toggleQuestionVisibility(questionId, visibility, csrfToken); + const response = await toggleQuestionVisibility(questionId, visibility === 'true', csrfToken); - if (response.success) { + if (response?.success) { element.innerText = response.success; } else { - pushErrorNotification(response.error); + pushErrorNotification(response?.error ?? 'An error occurred'); } - - console.log(questionId, visibility, csrfToken); }); }); } diff --git a/phpmyfaq/admin/assets/src/content/stickyfaqs.test.ts b/phpmyfaq/admin/assets/src/content/stickyfaqs.test.ts new file mode 100644 index 0000000000..8c9dd20344 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/stickyfaqs.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleStickyFaqs } from './stickyfaqs'; + +vi.mock('sortablejs', () => ({ default: { create: vi.fn() } })); + +vi.mock('bootstrap', () => { + const showFn = vi.fn(); + const hideFn = vi.fn(); + return { + Modal: vi.fn().mockImplementation(() => ({ + show: showFn, + hide: hideFn, + })), + }; +}); + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +vi.mock('../api/sticky-faqs', () => ({ + updateStickyFaqsOrder: vi.fn(), + removeStickyFaq: vi.fn(), +})); + +import Sortable from 'sortablejs'; +import { pushErrorNotification } from '../../../../assets/src/utils'; + +describe('handleStickyFaqs', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('should do nothing when #stickyFAQs element does not exist', () => { + document.body.innerHTML = '
'; + + handleStickyFaqs(); + + expect(Sortable.create).not.toHaveBeenCalled(); + }); + + it('should create Sortable instance when #stickyFAQs element exists', () => { + document.body.innerHTML = '
'; + + handleStickyFaqs(); + + expect(Sortable.create).toHaveBeenCalledTimes(1); + const callArgs = (Sortable.create as ReturnType).mock.calls[0]; + const element = callArgs[0] as HTMLElement; + expect(element.id).toBe('stickyFAQs'); + const options = callArgs[1] as Record; + expect(options.animation).toBe(150); + expect(options.draggable).toBe('.list-group-item'); + expect(options.handle).toBe('.drag-handle'); + expect(options.sort).toBe(true); + expect(options.filter).toBe('.sortable-disabled'); + expect(options.dataIdAttr).toBe('data-pmf-faqid'); + expect(typeof options.onEnd).toBe('function'); + }); + + it('should show error notification when unstick button has missing attributes', async () => { + document.body.innerHTML = ` +
+
+ +
+
+ `; + + handleStickyFaqs(); + + const button = document.querySelector('.js-unstick-button') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // showConfirmModal will resolve false because the modal elements are not in the DOM + // so it won't get to the missing attributes check; it just returns early. + // The console.error for missing modal should have been called. + expect(console.error).toHaveBeenCalledWith('Confirmation modal not found in DOM'); + }); + + it('should resolve false when confirm modal elements are not found in DOM', async () => { + document.body.innerHTML = ` +
+
+ +
+
+ `; + + handleStickyFaqs(); + + const button = document.querySelector('.js-unstick-button') as HTMLButtonElement; + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // showConfirmModal resolves false because #confirmUnstickyModal is not in DOM + // so removeStickyFaq should not be called, and no error notification for missing attributes + expect(console.error).toHaveBeenCalledWith('Confirmation modal not found in DOM'); + expect(pushErrorNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/stickyfaqs.ts b/phpmyfaq/admin/assets/src/content/stickyfaqs.ts index 207b8dd47f..b437942164 100644 --- a/phpmyfaq/admin/assets/src/content/stickyfaqs.ts +++ b/phpmyfaq/admin/assets/src/content/stickyfaqs.ts @@ -14,57 +14,161 @@ */ import Sortable from 'sortablejs'; +import { Modal } from 'bootstrap'; import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; +import { updateStickyFaqsOrder, removeStickyFaq } from '../api/sticky-faqs'; + +/** + * Show a Bootstrap confirmation modal using the template modal + * @param message The confirmation message to display + * @returns Promise that resolves to true if confirmed, false if cancelled + */ +const showConfirmModal = (message: string): Promise => { + return new Promise((resolve) => { + const modal = document.getElementById('confirmUnstickyModal'); + const modalBody = document.getElementById('confirmUnstickyModalBody'); + const confirmBtn = document.getElementById('confirmUnstickyModalConfirm'); + + if (!modal || !modalBody || !confirmBtn) { + console.error('Confirmation modal not found in DOM'); + resolve(false); + return; + } + + // Set the message + modalBody.textContent = message; + + // Initialize Bootstrap modal + const bsModal = new Modal(modal); + + // Remove old event listeners by cloning the button + const newConfirmBtn = confirmBtn.cloneNode(true) as HTMLElement; + confirmBtn.parentNode?.replaceChild(newConfirmBtn, confirmBtn); + + // Handle confirm button + const handleConfirm = () => { + bsModal.hide(); + resolve(true); + newConfirmBtn.removeEventListener('click', handleConfirm); + }; + + newConfirmBtn.addEventListener('click', handleConfirm); + + // Handle modal close/cancel + const handleHidden = () => { + modal.removeEventListener('hidden.bs.modal', handleHidden); + resolve(false); + }; + + modal.addEventListener('hidden.bs.modal', handleHidden, { once: true }); + + // Show modal + bsModal.show(); + }); +}; export const handleStickyFaqs = (): void => { const stickyFAQs = document.getElementById('stickyFAQs') as HTMLElement | null; - if (stickyFAQs) { - Sortable.create(stickyFAQs, { - animation: 100, - draggable: '.list-group-item', - handle: '.list-group-item', - sort: true, - filter: '.sortable-disabled', - dataIdAttr: 'data-pmf-faqid', - onEnd: async (event: Sortable.SortableEvent) => { - const currentOrder = Array.from(event.from.children).map((item) => { - return item.getAttribute('data-pmf-faqid'); - }) as string[]; - await saveStatus(currentOrder); - }, - }); + + if (!stickyFAQs) { + return; } + + Sortable.create(stickyFAQs, { + animation: 150, + draggable: '.list-group-item', + handle: '.drag-handle', + sort: true, + filter: '.sortable-disabled', + dataIdAttr: 'data-pmf-faqid', + onEnd: async (event: Sortable.SortableEvent) => { + const currentOrder = event.from + ? Array.from(event.from.children) + .map((item) => item.getAttribute('data-pmf-faqid')) + .filter((id): id is string => id !== null) + : []; + await saveStatus(currentOrder, stickyFAQs); + }, + }); + + stickyFAQs.addEventListener('click', async (event: Event) => { + const target = event.target as HTMLElement; + const btn = target.closest('.js-unstick-button') as HTMLButtonElement | null; + + if (!btn) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const msgConfirm = stickyFAQs.getAttribute('data-lang-confirm') || 'Do you really want to remove this FAQ?'; + const msgSuccess = stickyFAQs.getAttribute('data-lang-success') || 'Successfully removed.'; + + const confirmed = await showConfirmModal(msgConfirm); + if (!confirmed) { + return; + } + + btn.disabled = true; + + const faqId = btn.getAttribute('data-pmf-faq-id'); + const categoryId = btn.getAttribute('data-pmf-category-id'); + const csrfToken = btn.getAttribute('data-pmf-csrf'); + const lang = btn.getAttribute('data-pmf-lang'); + + if (!faqId || !categoryId || !csrfToken || !lang) { + pushErrorNotification('Missing required FAQ information; cannot remove sticky FAQ.'); + btn.disabled = false; + return; + } + + try { + await removeStickyFaq(faqId, categoryId, csrfToken, lang); + + const row = btn.closest('.list-group-item') as HTMLElement | null; + + if (!row) { + pushErrorNotification('Could not find FAQ item in the list.'); + btn.disabled = false; + return; + } + + row.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + row.style.opacity = '0'; + row.style.transform = 'translateX(20px)'; + + setTimeout(() => { + row.remove(); + pushNotification(msgSuccess); + }, 300); + } catch (error) { + if (error instanceof Error) { + pushErrorNotification(error.message); + } else { + console.error('Unknown error:', error); + pushErrorNotification('Error communicating with API'); + } + btn.disabled = false; + } + }); }; -const saveStatus = async (currentOrder: string[]): Promise => { - const stickyFAQs = document.getElementById('stickyFAQs') as HTMLElement; - const successAlert = document.getElementById('successAlert') as HTMLElement | null; - const csrf = stickyFAQs.getAttribute('data-csrf') as string; +const saveStatus = async (currentOrder: string[], container: HTMLElement): Promise => { + const successAlert = document.getElementById('successAlert'); + const csrf = container.getAttribute('data-csrf') || ''; + + if (!csrf) { + console.warn('CSRF token not found on container'); + } if (successAlert) { successAlert.remove(); } try { - const response = await fetch('./api/faqs/sticky/order', { - method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - faqIds: currentOrder, - csrf: csrf, - }), - }); - - if (response.ok) { - const jsonResponse = await response.json(); - pushNotification(jsonResponse.success); - } else { - const errorResponse = await response.json(); - throw new Error('Network response was not ok: ' + JSON.stringify(errorResponse)); - } + const response = await updateStickyFaqsOrder(currentOrder, csrf); + pushNotification(response?.success || 'Order updated'); } catch (error) { if (error instanceof Error) { pushErrorNotification(error.message); diff --git a/phpmyfaq/admin/assets/src/content/tags.test.ts b/phpmyfaq/admin/assets/src/content/tags.test.ts new file mode 100644 index 0000000000..72fc82c701 --- /dev/null +++ b/phpmyfaq/admin/assets/src/content/tags.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleTags } from './tags'; +import { deleteTag } from '../api'; +import { pushNotification } from '../../../../assets/src/utils'; +import { fetchJson } from '../api/fetch-wrapper'; + +vi.mock('../api'); +vi.mock('../api/fetch-wrapper'); +vi.mock('autocompleter', () => ({ default: vi.fn() })); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +describe('handleTags', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('should not throw when no buttons or form exist', () => { + document.body.innerHTML = '
'; + + expect(() => handleTags()).not.toThrow(); + }); + + describe('edit button', () => { + const setupEditDom = () => { + document.body.innerHTML = ` + + `; + const span = document.createElement('span'); + span.id = 'tag-id-1'; + span.innerText = 'Test Tag'; + document.body.prepend(span); + }; + + it('should replace span with input on first click', () => { + setupEditDom(); + + handleTags(); + + const editButton = document.querySelector('.btn-edit') as HTMLButtonElement; + editButton.click(); + + const input = document.querySelector('input[id="tag-id-1"]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.tagName).toBe('INPUT'); + expect(input.value).toBe('Test Tag'); + expect(input.name).toBe('tag'); + expect(input.type).toBe('text'); + + const span = document.querySelector('span[id="tag-id-1"]'); + expect(span).toBeNull(); + }); + + it('should replace input back with span on second click', () => { + setupEditDom(); + + handleTags(); + + const editButton = document.querySelector('.btn-edit') as HTMLButtonElement; + + // First click: span -> input + editButton.click(); + const input = document.querySelector('input[id="tag-id-1"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + // Second click: input -> span + editButton.click(); + const span = document.querySelector('span[id="tag-id-1"]') as HTMLSpanElement; + expect(span).not.toBeNull(); + expect(span.tagName).toBe('SPAN'); + expect(span.innerHTML).toBe('Test Tag'); + + const inputAfter = document.querySelector('input[id="tag-id-1"]'); + expect(inputAfter).toBeNull(); + }); + }); + + describe('delete button', () => { + const setupDeleteDom = () => { + document.body.innerHTML = ` + + + + + + + +
+ Test Tag + + +
+ `; + }; + + it('should call deleteTag and remove row on success', async () => { + setupDeleteDom(); + + (deleteTag as Mock).mockResolvedValue({ success: 'Tag deleted successfully' }); + + handleTags(); + + const deleteButton = document.querySelector('.btn-delete') as HTMLButtonElement; + deleteButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteTag).toHaveBeenCalledWith('1'); + expect(pushNotification).toHaveBeenCalledWith('Tag deleted successfully'); + + const row = document.getElementById('pmf-row-tag-id-1'); + expect(row).toBeNull(); + }); + + it('should log error on failure', async () => { + setupDeleteDom(); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + (deleteTag as Mock).mockResolvedValue({ error: 'Tag deletion failed' }); + + handleTags(); + + const deleteButton = document.querySelector('.btn-delete') as HTMLButtonElement; + deleteButton.click(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(deleteTag).toHaveBeenCalledWith('1'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Network response was not ok:', 'Tag deletion failed'); + + // Row should still be present + const row = document.getElementById('pmf-row-tag-id-1'); + expect(row).not.toBeNull(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('form submit', () => { + const setupFormDom = () => { + document.body.innerHTML = ` + + + + + + + +
+ + + +
+
+ +
+ `; + }; + + it('should call fetchJson and replace input with span and badge on success', async () => { + setupFormDom(); + + (fetchJson as Mock).mockResolvedValue({ success: 'Tag saved' }); + + handleTags(); + + // Focus the input to simulate user editing + const input = document.querySelector('input[id="tag-id-1"]') as HTMLInputElement; + input.focus(); + + const form = document.getElementById('tag-form') as HTMLFormElement; + form.dispatchEvent(new Event('submit')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchJson).toHaveBeenCalledWith('./api/content/tag', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + csrf: 'test-csrf', + id: '1', + tag: 'Updated Tag', + }), + }); + + // Input should be replaced with a span + const span = document.querySelector('span[id="tag-id-1"]') as HTMLSpanElement; + expect(span).not.toBeNull(); + expect(span.innerHTML).toContain('Updated Tag'); + + // The badge should be present inside the span + const badge = span.querySelector('.badge'); + expect(badge).not.toBeNull(); + + const inputAfter = document.querySelector('input[id="tag-id-1"]'); + expect(inputAfter).toBeNull(); + }); + + it('should show error alert on failure', async () => { + setupFormDom(); + + (fetchJson as Mock).mockRejectedValue(new Error('Save failed')); + + handleTags(); + + // Focus the input to simulate user editing + const input = document.querySelector('input[id="tag-id-1"]') as HTMLInputElement; + input.focus(); + + const form = document.getElementById('tag-form') as HTMLFormElement; + form.dispatchEvent(new Event('submit')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const alert = document.querySelector('.alert.alert-danger') as HTMLElement; + expect(alert).not.toBeNull(); + expect(alert.innerText).toBe('Save failed'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/content/tags.ts b/phpmyfaq/admin/assets/src/content/tags.ts index 2a62204d7e..5ff3eb5261 100644 --- a/phpmyfaq/admin/assets/src/content/tags.ts +++ b/phpmyfaq/admin/assets/src/content/tags.ts @@ -16,11 +16,22 @@ import autocomplete, { AutocompleteItem } from 'autocompleter'; import { addElement, pushNotification } from '../../../../assets/src/utils'; import { deleteTag, fetchTags } from '../api'; +import { fetchJson } from '../api/fetch-wrapper'; interface Tag extends AutocompleteItem { tagName: string; } +interface DeleteTagResponse { + success?: string; + error?: string; +} + +interface TagResponse { + id: string; + name: string; +} + export const handleTags = (): void => { const editTagButtons = document.querySelectorAll('.btn-edit'); const deleteButtons = document.querySelectorAll('.btn-delete'); @@ -63,7 +74,7 @@ export const handleTags = (): void => { const target = event.target as HTMLElement; const tagId = target.getAttribute('data-pmf-id') as string; - const response = await deleteTag(tagId); + const response = (await deleteTag(tagId)) as DeleteTagResponse; if (response.success) { pushNotification(response.success); const row = document.getElementById(`pmf-row-tag-id-${tagId}`) as HTMLElement; @@ -76,7 +87,7 @@ export const handleTags = (): void => { } if (tagForm) { - tagForm.addEventListener('submit', (event: Event) => { + tagForm.addEventListener('submit', async (event: Event) => { event.preventDefault(); const input = document.querySelector('input:focus') as HTMLInputElement; @@ -91,49 +102,44 @@ export const handleTags = (): void => { const tag = input.value; const csrf = (document.querySelector('input[name=pmf-csrf-token]') as HTMLInputElement).value; - fetch('./api/content/tag', { - method: 'PUT', - headers: { - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - csrf: csrf, - id: tagId, - tag: tag, - }), - }) - .then(async (response) => { - if (response.ok) { - return response.json(); - } - throw new Error('Network response was not ok: ', { cause: { response } }); - }) - .then(() => { - input.replaceWith( - addElement( - 'span', - { - innerHTML: input.value.replace(/\//g, '/'), - id: `tag-id-${tagId}`, - }, - [ - addElement('span', { - classList: 'ms-1 badge rounded-pill bg-success', - innerHTML: '✓', - }), - ] - ) - ); - }) - .catch(async (error) => { - const errorMessage = await error.cause.response.json(); - const table = document.querySelector('.table') as HTMLElement; - table.insertAdjacentElement( - 'beforebegin', - addElement('div', { classList: 'alert alert-danger', innerText: errorMessage }) - ); + try { + await fetchJson('./api/content/tag', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + csrf: csrf, + id: tagId, + tag: tag, + }), }); + + input.replaceWith( + addElement( + 'span', + { + innerHTML: input.value.replace(/\//g, '/'), + id: `tag-id-${tagId}`, + }, + [ + addElement('span', { + classList: 'ms-1 badge rounded-pill bg-success', + innerHTML: '✓', + }), + ] + ) + ); + } catch (error) { + const table = document.querySelector('.table') as HTMLElement; + table.insertAdjacentElement( + 'beforebegin', + addElement('div', { + classList: 'alert alert-danger', + innerText: error instanceof Error ? error.message : 'An error occurred', + }) + ); + } }); } @@ -154,14 +160,14 @@ export const handleTags = (): void => { }, fetch: async (text, callback) => { let match = text.toLowerCase(); - const tags = await fetchTags(match); + const tags = (await fetchTags(match)) as TagResponse[]; const tagItems: Tag[] = tags - .filter((tag) => { + .filter((tag: TagResponse) => { const lastCommaIndex = match.lastIndexOf(','); match = match.substring(lastCommaIndex + 1); return tag.name.toLowerCase().indexOf(match) !== -1; }) - .map((tag) => ({ + .map((tag: TagResponse) => ({ tagName: tag.name, label: tag.name, })); diff --git a/phpmyfaq/admin/assets/src/dashboard.test.ts b/phpmyfaq/admin/assets/src/dashboard.test.ts new file mode 100644 index 0000000000..136e139bb8 --- /dev/null +++ b/phpmyfaq/admin/assets/src/dashboard.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { getLatestVersion } from './dashboard'; + +vi.mock('masonry-layout', () => ({ + default: vi.fn(), +})); + +vi.mock('chart.js', () => ({ + Chart: { + register: vi.fn(), + }, + registerables: [], +})); + +describe('dashboard getLatestVersion', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+
+ `; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('renders success alert when API returns success message', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ success: 'Up to date' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await getLatestVersion(); + + const loader = document.getElementById('version-loader'); + const versionText = document.getElementById('phpmyfaq-latest-version'); + + expect(loader?.classList.contains('d-none')).toBe(true); + const alert = versionText?.nextElementSibling as HTMLElement | null; + expect(alert?.classList.contains('alert-success')).toBe(true); + // jsdom doesn't reliably implement innerText; only assert the element is added. + expect(alert?.textContent).not.toBeNull(); + }); + + it('renders error alert when API response is not ok', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: vi.fn().mockResolvedValue({}), + }); + vi.stubGlobal('fetch', fetchMock); + + await getLatestVersion(); + + const loader = document.getElementById('version-loader'); + const versionText = document.getElementById('phpmyfaq-latest-version'); + + expect(loader?.classList.contains('d-none')).toBe(true); + const alert = versionText?.nextElementSibling as HTMLElement | null; + expect(alert?.classList.contains('alert-danger')).toBe(true); + expect(alert?.textContent).not.toBeNull(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/group/groups.test.ts b/phpmyfaq/admin/assets/src/group/groups.test.ts new file mode 100644 index 0000000000..c5330a4b38 --- /dev/null +++ b/phpmyfaq/admin/assets/src/group/groups.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleGroups } from './groups'; +import { fetchAllGroups, fetchAllMembers, fetchAllUsersForGroups, fetchGroup, fetchGroupRights } from '../api'; +import { selectAll, unSelectAll } from '../utils'; + +vi.mock('../api'); +vi.mock('../utils'); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)); +}; + +const setupFullDom = (): void => { + document.body.innerHTML = ` + + + + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + `; +}; + +const mockDefaultApis = (): void => { + (fetchAllGroups as Mock).mockResolvedValue([ + { group_id: '1', name: 'Admins' }, + { group_id: '2', name: 'Users' }, + ]); + (fetchAllUsersForGroups as Mock).mockResolvedValue([ + { user_id: '10', login: 'alice' }, + { user_id: '20', login: 'bob' }, + ]); + (fetchAllMembers as Mock).mockResolvedValue([]); + (fetchGroup as Mock).mockResolvedValue({ + group_id: '1', + name: 'Admins', + description: 'Admin group', + auto_join: '1', + }); + (fetchGroupRights as Mock).mockResolvedValue(['1', '3']); +}; + +describe('handleGroups', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.spyOn(window, 'alert').mockImplementation(() => {}); + }); + + it('should return early when #group_list_select is missing', async () => { + document.body.innerHTML = '
'; + + await handleGroups(); + + expect(fetchAllGroups).not.toHaveBeenCalled(); + }); + + it('should populate group select with fetched groups', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + const select = document.getElementById('group_list_select') as HTMLSelectElement; + expect(select.options.length).toBe(2); + expect(select.options[0].value).toBe('1'); + expect(select.options[0].textContent).toBe('Admins'); + expect(select.options[1].value).toBe('2'); + expect(select.options[1].textContent).toBe('Users'); + }); + + it('should populate user list on initial load', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + const userList = document.getElementById('group_user_list') as HTMLSelectElement; + expect(userList.options.length).toBe(2); + expect(userList.options[0].value).toBe('10'); + expect(userList.options[0].textContent).toBe('alice'); + expect(userList.options[1].value).toBe('20'); + expect(userList.options[1].textContent).toBe('bob'); + }); + + it('should call selectAll when select all users button is clicked', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + const btn = document.getElementById('select_all_group_user_list') as HTMLButtonElement; + btn.click(); + + expect(selectAll).toHaveBeenCalledWith('group_user_list'); + }); + + it('should call unSelectAll when unselect all users button is clicked', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + const btn = document.getElementById('unselect_all_group_user_list') as HTMLButtonElement; + btn.click(); + + expect(unSelectAll).toHaveBeenCalledWith('group_user_list'); + }); + + it('should call selectAll when select all members button is clicked', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + const btn = document.getElementById('select_all_members') as HTMLButtonElement; + btn.click(); + + expect(selectAll).toHaveBeenCalledWith('group_member_list'); + }); + + it('should call unSelectAll when unselect all members button is clicked', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + const btn = document.getElementById('unselect_all_members') as HTMLButtonElement; + btn.click(); + + expect(unSelectAll).toHaveBeenCalledWith('group_member_list'); + }); + + describe('handleGroupSelect (on change)', () => { + it('should fetch group data and enable buttons when a group is selected', async () => { + setupFullDom(); + mockDefaultApis(); + (fetchAllMembers as Mock).mockResolvedValue([{ user_id: '10', login: 'alice' }]); + + await handleGroups(); + + const select = document.getElementById('group_list_select') as HTMLSelectElement; + select.value = '1'; + select.dispatchEvent(new Event('change')); + + await flushPromises(); + + // Group data should be filled + expect(fetchGroup).toHaveBeenCalledWith('1'); + expect((document.getElementById('update_group_id') as HTMLInputElement).value).toBe('1'); + expect((document.getElementById('update_group_name') as HTMLInputElement).value).toBe('Admins'); + expect((document.getElementById('update_group_description') as HTMLInputElement).value).toBe('Admin group'); + expect((document.getElementById('update_group_auto_join') as HTMLInputElement).checked).toBe(true); + + // Group rights should be checked + expect(fetchGroupRights).toHaveBeenCalledWith('1'); + expect((document.getElementById('group_right_1') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('group_right_3') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('group_right_5') as HTMLInputElement).checked).toBe(false); + + // Members should be populated + expect(fetchAllMembers).toHaveBeenCalledWith('1'); + const memberList = document.getElementById('group_member_list') as HTMLSelectElement; + expect(memberList.options.length).toBe(1); + expect(memberList.options[0].value).toBe('10'); + + // Buttons should be enabled + expect((document.getElementById('saveGroupDetails') as HTMLButtonElement).disabled).toBe(false); + expect((document.getElementById('saveMembersList') as HTMLButtonElement).disabled).toBe(false); + expect((document.getElementById('saveGroupRights') as HTMLButtonElement).disabled).toBe(false); + expect((document.getElementById('deleteGroup') as HTMLButtonElement).disabled).toBe(false); + + // Permission checkboxes should be enabled + document.querySelectorAll('.permission').forEach((perm) => { + expect(perm.disabled).toBe(false); + }); + }); + + it('should set auto_join to false when auto_join is "0"', async () => { + setupFullDom(); + mockDefaultApis(); + (fetchGroup as Mock).mockResolvedValue({ + group_id: '2', + name: 'Users', + description: '', + auto_join: '0', + }); + + await handleGroups(); + + const select = document.getElementById('group_list_select') as HTMLSelectElement; + select.value = '2'; + select.dispatchEvent(new Event('change')); + + await flushPromises(); + + expect((document.getElementById('update_group_auto_join') as HTMLInputElement).checked).toBe(false); + }); + }); + + describe('addGroupMembers', () => { + it('should add selected users to member list', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + // Select a group first (so option:checked works) + const groupSelect = document.getElementById('group_list_select') as HTMLSelectElement; + groupSelect.value = '1'; + groupSelect.dispatchEvent(new Event('change')); + await flushPromises(); + + // Select a user in the user list + const userList = document.getElementById('group_user_list') as HTMLSelectElement; + // After group select, user list is repopulated + const userOption = userList.options[0]; + if (userOption) { + userOption.selected = true; + } + + // Clear member list to start fresh + const memberList = document.getElementById('group_member_list') as HTMLSelectElement; + memberList.textContent = ''; + + // Click add member + const addBtn = document.querySelector('.pmf-add-member') as HTMLButtonElement; + addBtn.click(); + + // Member should be added + expect(memberList.options.length).toBe(1); + expect(memberList.options[0].selected).toBe(true); + }); + + it('should show alert when no group is selected', async () => { + setupFullDom(); + mockDefaultApis(); + + await handleGroups(); + + // Ensure no option is checked in group_list_select + const groupSelect = document.getElementById('group_list_select') as HTMLSelectElement; + groupSelect.innerHTML = ''; + + const addBtn = document.querySelector('.pmf-add-member') as HTMLButtonElement; + addBtn.click(); + + expect(window.alert).toHaveBeenCalledWith('Please choose a group.'); + }); + + it('should not add duplicate members', async () => { + setupFullDom(); + mockDefaultApis(); + (fetchAllMembers as Mock).mockResolvedValue([{ user_id: '10', login: 'alice' }]); + + await handleGroups(); + + // Select a group + const groupSelect = document.getElementById('group_list_select') as HTMLSelectElement; + groupSelect.value = '1'; + groupSelect.dispatchEvent(new Event('change')); + await flushPromises(); + + // alice (user_id 10) is already a member + const userList = document.getElementById('group_user_list') as HTMLSelectElement; + // Select alice in user list + for (const opt of userList.options) { + opt.selected = opt.value === '10'; + } + + const memberCountBefore = (document.getElementById('group_member_list') as HTMLSelectElement).options.length; + + const addBtn = document.querySelector('.pmf-add-member') as HTMLButtonElement; + addBtn.click(); + + const memberCountAfter = (document.getElementById('group_member_list') as HTMLSelectElement).options.length; + expect(memberCountAfter).toBe(memberCountBefore); + }); + }); + + describe('removeGroupMembers', () => { + it('should remove selected members from member list', async () => { + setupFullDom(); + mockDefaultApis(); + (fetchAllMembers as Mock).mockResolvedValue([ + { user_id: '10', login: 'alice' }, + { user_id: '20', login: 'bob' }, + ]); + + await handleGroups(); + + // Select a group to populate members + const groupSelect = document.getElementById('group_list_select') as HTMLSelectElement; + groupSelect.value = '1'; + groupSelect.dispatchEvent(new Event('change')); + await flushPromises(); + + const memberList = document.getElementById('group_member_list') as HTMLSelectElement; + expect(memberList.options.length).toBe(2); + + // Select only alice for removal + for (const opt of memberList.options) { + opt.selected = opt.value === '10'; + } + + const removeBtn = document.querySelector('.pmf-remove-member') as HTMLButtonElement; + removeBtn.click(); + + expect(memberList.options.length).toBe(1); + expect(memberList.options[0].value).toBe('20'); + }); + + it('should show alert when no member is selected', async () => { + setupFullDom(); + mockDefaultApis(); + (fetchAllMembers as Mock).mockResolvedValue([{ user_id: '10', login: 'alice' }]); + + await handleGroups(); + + // Select a group to populate members + const groupSelect = document.getElementById('group_list_select') as HTMLSelectElement; + groupSelect.value = '1'; + groupSelect.dispatchEvent(new Event('change')); + await flushPromises(); + + // Deselect all members + const memberList = document.getElementById('group_member_list') as HTMLSelectElement; + for (const opt of memberList.options) { + opt.selected = false; + } + + const removeBtn = document.querySelector('.pmf-remove-member') as HTMLButtonElement; + removeBtn.click(); + + expect(window.alert).toHaveBeenCalledWith('Please choose a member.'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/index.ts b/phpmyfaq/admin/assets/src/index.ts index 262837ee1d..0a8b0d0b91 100644 --- a/phpmyfaq/admin/assets/src/index.ts +++ b/phpmyfaq/admin/assets/src/index.ts @@ -20,10 +20,12 @@ import { handleCreateReport, handleDeleteAdminLog, handleDeleteSessions, + handleExportAdminLog, handleSessions, handleSessionsFilter, handleStatistics, handleTruncateSearchTerms, + handleVerifyAdminLog, } from './statistics'; import { handleConfiguration, @@ -58,6 +60,10 @@ import { handleAddNews, handleNews, handleEditNews, + handleAddPage, + handlePages, + handleEditPage, + handleTranslatePage, handleSaveFaqData, handleUpdateQuestion, handleRefreshAttachments, @@ -65,6 +71,9 @@ import { handleResetCategoryImage, handleResetButton, handleDeleteFaqEditorModal, + handleFaqTranslate, + handleCategoryTranslate, + handleFleschReadingEase, handleDeleteFaqModal, } from './content'; import { handleUserList, handleUsers } from './user'; @@ -102,11 +111,13 @@ document.addEventListener('DOMContentLoaded', async (): Promise => { // Content → Categories handleCategories(); handleResetCategoryImage(); + handleCategoryTranslate(); await handleCategoryDelete(); // Content → add/edit FAQs renderEditor(); handleFaqForm(); + handleFaqTranslate(); handleMarkdownForm(); handleAttachmentUploads(); handleFileFilter(); @@ -114,6 +125,7 @@ document.addEventListener('DOMContentLoaded', async (): Promise => { handleUpdateQuestion(); handleDeleteFaqEditorModal(); handleResetButton(); + handleFleschReadingEase(); await handleFaqOverview(); handleDeleteFaqModal(); @@ -142,6 +154,8 @@ document.addEventListener('DOMContentLoaded', async (): Promise => { // Statistics handleDeleteAdminLog(); + handleExportAdminLog(); + await handleVerifyAdminLog(); handleStatistics(); handleCreateReport(); handleTruncateSearchTerms(); @@ -181,6 +195,12 @@ document.addEventListener('DOMContentLoaded', async (): Promise => { handleNews(); handleEditNews(); + // Custom Pages + handleAddPage(); + handlePages(); + handleEditPage(); + handleTranslatePage(); + // Initialize tooltips everywhere initializeTooltips(); }); diff --git a/phpmyfaq/admin/assets/src/interfaces/response.ts b/phpmyfaq/admin/assets/src/interfaces/response.ts index 3e97871682..e357fc08a8 100644 --- a/phpmyfaq/admin/assets/src/interfaces/response.ts +++ b/phpmyfaq/admin/assets/src/interfaces/response.ts @@ -5,6 +5,7 @@ export interface Response { error?: string; status?: string; data?: string; + delete?: boolean; } export interface GlossaryResponse { diff --git a/phpmyfaq/admin/assets/src/plugins/code-snippet/code-snippet.test.ts b/phpmyfaq/admin/assets/src/plugins/code-snippet/code-snippet.test.ts new file mode 100644 index 0000000000..b0714c4bfd --- /dev/null +++ b/phpmyfaq/admin/assets/src/plugins/code-snippet/code-snippet.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockIconSet = vi.fn(); +const mockPluginsAdd = vi.fn(); + +vi.mock('jodit', () => ({ + Jodit: { + modules: { + Icon: { + set: mockIconSet, + }, + }, + plugins: { + add: mockPluginsAdd, + }, + }, +})); + +describe('code-snippet.svg', () => { + it('should export a string containing SVG markup', async () => { + const { default: svgContent } = await import('./code-snippet.svg.js'); + expect(typeof svgContent).toBe('string'); + expect(svgContent).toContain(' { + const { default: svgContent } = await import('./code-snippet.svg.js'); + expect(svgContent).toContain(' { + const mockDialog = { + setMod: vi.fn(), + setHeader: vi.fn(), + setContent: vi.fn(), + setSize: vi.fn(), + open: vi.fn(), + close: vi.fn(), + }; + + const mockEditor = { + registerButton: vi.fn(), + registerCommand: vi.fn(), + dlg: vi.fn(), + selection: { insertHTML: vi.fn() }, + events: { fire: vi.fn() }, + o: { theme: 'default' }, + }; + + const resetMockChain = () => { + mockDialog.setMod.mockReturnThis(); + mockDialog.setHeader.mockReturnThis(); + mockDialog.setContent.mockReturnThis(); + mockDialog.setSize.mockReturnThis(); + mockEditor.dlg.mockReturnValue(mockDialog); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + document.body.innerHTML = ''; + resetMockChain(); + }); + + const importPlugin = async () => { + await import('./code-snippet'); + }; + + const getPluginCallback = (): ((editor: typeof mockEditor) => void) => { + return mockPluginsAdd.mock.calls[0][1] as (editor: typeof mockEditor) => void; + }; + + const getCommandFn = (): (() => void) => { + return mockEditor.registerCommand.mock.calls[0][1] as () => void; + }; + + it('should register icon via Jodit.modules.Icon.set with name codeSnippet', async () => { + await importPlugin(); + + expect(mockIconSet).toHaveBeenCalledWith('codeSnippet', expect.any(String)); + }); + + it('should register plugin via Jodit.plugins.add with name codeSnippet', async () => { + await importPlugin(); + + expect(mockPluginsAdd).toHaveBeenCalledWith('codeSnippet', expect.any(Function)); + }); + + it('should register button with tooltip Insert Source Code Snippet', async () => { + await importPlugin(); + + const pluginCallback = getPluginCallback(); + pluginCallback(mockEditor); + + expect(mockEditor.registerButton).toHaveBeenCalledWith({ + name: 'codeSnippet', + group: 'insert', + options: { + tooltip: 'Insert Source Code Snippet', + }, + }); + }); + + it('should register command named codeSnippet', async () => { + await importPlugin(); + + const pluginCallback = getPluginCallback(); + pluginCallback(mockEditor); + + expect(mockEditor.registerCommand).toHaveBeenCalledWith('codeSnippet', expect.any(Function)); + }); + + it('should create and open dialog with correct header', async () => { + await importPlugin(); + + const pluginCallback = getPluginCallback(); + pluginCallback(mockEditor); + + const commandFn = getCommandFn(); + + document.body.innerHTML = ` + + + + `; + + commandFn(); + + expect(mockEditor.dlg).toHaveBeenCalledWith({ closeOnClickOverlay: true }); + expect(mockDialog.setHeader).toHaveBeenCalledWith('Insert Source Code Snippet'); + expect(mockDialog.setContent).toHaveBeenCalledWith(expect.any(String)); + expect(mockDialog.open).toHaveBeenCalled(); + }); + + it('should insert pre/code HTML with selected language class on add button click', async () => { + await importPlugin(); + + const pluginCallback = getPluginCallback(); + pluginCallback(mockEditor); + + const commandFn = getCommandFn(); + + document.body.innerHTML = ` + + + + `; + + commandFn(); + + const addButton = document.getElementById('add-code-snippet-button') as HTMLButtonElement; + addButton.click(); + + expect(mockEditor.selection.insertHTML).toHaveBeenCalledWith( + '
console.log("hello")
' + ); + }); + + it('should HTML-encode special characters (& < > " \')', async () => { + await importPlugin(); + + const pluginCallback = getPluginCallback(); + pluginCallback(mockEditor); + + const commandFn = getCommandFn(); + + document.body.innerHTML = ` + + + + `; + + const textarea = document.getElementById('code') as HTMLTextAreaElement; + textarea.value = '&<>"\''; + + commandFn(); + + const addButton = document.getElementById('add-code-snippet-button') as HTMLButtonElement; + addButton.click(); + + const insertedHtml = mockEditor.selection.insertHTML.mock.calls[0][0] as string; + expect(insertedHtml).toContain('&'); + expect(insertedHtml).toContain('<'); + expect(insertedHtml).toContain('>'); + expect(insertedHtml).toContain('"'); + expect(insertedHtml).toContain('''); + }); + + it('should fire change event and close dialog on add button click', async () => { + await importPlugin(); + + const pluginCallback = getPluginCallback(); + pluginCallback(mockEditor); + + const commandFn = getCommandFn(); + + document.body.innerHTML = ` + + + + `; + + commandFn(); + + const addButton = document.getElementById('add-code-snippet-button') as HTMLButtonElement; + addButton.click(); + + expect(mockEditor.events.fire).toHaveBeenCalledWith('change'); + expect(mockDialog.close).toHaveBeenCalled(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/plugins/phpmyfaq/phpmyfaq.test.ts b/phpmyfaq/admin/assets/src/plugins/phpmyfaq/phpmyfaq.test.ts new file mode 100644 index 0000000000..fa14599052 --- /dev/null +++ b/phpmyfaq/admin/assets/src/plugins/phpmyfaq/phpmyfaq.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; + +// Mock jodit before importing the plugin +const mockIconSet = vi.fn(); +const mockPluginsAdd = vi.fn(); + +vi.mock('jodit', () => ({ + Jodit: { + modules: { + Icon: { + set: mockIconSet, + }, + }, + plugins: { + add: mockPluginsAdd, + }, + }, +})); + +vi.mock('../../api', () => ({ + fetchFaqsByAutocomplete: vi.fn(), +})); + +import phpmyfaqSvg from './phpmyfaq.svg'; +import { fetchFaqsByAutocomplete } from '../../api'; + +// Import the plugin to trigger its top-level side effects (Icon.set, plugins.add) +await import('./phpmyfaq.ts'); + +// Capture the plugin callback registered during top-level import +const pluginAddCall = mockPluginsAdd.mock.calls[0]; +const capturedPluginCallback = pluginAddCall[1] as (editor: Record) => void; + +describe('phpmyfaq.svg', () => { + it('should export a string containing SVG markup', () => { + expect(typeof phpmyfaqSvg).toBe('string'); + expect(phpmyfaqSvg).toContain(' { + expect(phpmyfaqSvg).toContain(' { + let editor: Record; + let dialog: Record; + let consoleErrorSpy: ReturnType; + let alertSpy: ReturnType; + + beforeEach(() => { + document.body.innerHTML = ''; + + dialog = { + setMod: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), + setContent: vi.fn().mockReturnThis(), + setSize: vi.fn().mockReturnThis(), + open: vi.fn(), + close: vi.fn(), + }; + + editor = { + registerButton: vi.fn(), + registerCommand: vi.fn(), + dlg: vi.fn().mockReturnValue(dialog), + selection: { insertHTML: vi.fn() }, + o: { theme: 'default' }, + }; + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + (fetchFaqsByAutocomplete as Mock).mockReset(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + alertSpy.mockRestore(); + }); + + it('should register the icon via Jodit.modules.Icon.set', () => { + expect(mockIconSet).toHaveBeenCalledWith('phpmyfaq', expect.any(String)); + }); + + it('should register the plugin via Jodit.plugins.add with name phpMyFAQ', () => { + expect(mockPluginsAdd).toHaveBeenCalledWith('phpMyFAQ', expect.any(Function)); + }); + + it('should register a button with correct config when plugin callback is invoked', () => { + capturedPluginCallback(editor); + + expect(editor.registerButton).toHaveBeenCalledWith({ + name: 'phpMyFAQ', + group: 'insert', + }); + }); + + it('should register a command named phpMyFAQ when plugin callback is invoked', () => { + capturedPluginCallback(editor); + + expect(editor.registerCommand).toHaveBeenCalledWith('phpMyFAQ', expect.any(Function)); + }); + + describe('command function', () => { + let commandFn: () => void; + + beforeEach(() => { + capturedPluginCallback(editor); + + const registerCommandCall = (editor.registerCommand as Mock).mock.calls[0]; + commandFn = registerCommandCall[1] as () => void; + }); + + const injectDialogHtml = () => { + // The plugin calls dialog.setContent with the HTML, but our mock doesn't add it to DOM. + // We manually inject it to test the event listeners. + document.body.innerHTML = ` + +
+
+ + +
+
+
+
+ +
+ `; + }; + + it('should create and open a dialog', () => { + document.body.innerHTML = ''; + + const searchInput = document.createElement('input'); + searchInput.id = 'pmf-search-internal-links'; + document.body.appendChild(searchInput); + + const resultsContainer = document.createElement('div'); + resultsContainer.id = 'pmf-search-results'; + document.body.appendChild(resultsContainer); + + const selectButton = document.createElement('button'); + selectButton.id = 'select-faq-button'; + document.body.appendChild(selectButton); + + commandFn(); + + expect(editor.dlg).toHaveBeenCalledWith({ closeOnClickOverlay: true }); + expect(dialog.setMod).toHaveBeenCalledWith('theme', 'default'); + expect(dialog.setHeader).toHaveBeenCalledWith('phpMyFAQ Plugin'); + expect(dialog.setContent).toHaveBeenCalledWith(expect.any(String)); + expect(dialog.setSize).toHaveBeenCalled(); + expect(dialog.open).toHaveBeenCalled(); + }); + + it('should render radio buttons when search returns results', async () => { + injectDialogHtml(); + + (fetchFaqsByAutocomplete as Mock).mockResolvedValue({ + success: [{ url: '/faq/1', question: 'Test FAQ' }], + }); + + commandFn(); + + const searchInput = document.getElementById('pmf-search-internal-links') as HTMLInputElement; + searchInput.value = 'test'; + searchInput.dispatchEvent(new Event('keyup')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchFaqsByAutocomplete).toHaveBeenCalledWith('test', 'test-csrf-token'); + + const resultsContainer = document.getElementById('pmf-search-results') as HTMLDivElement; + expect(resultsContainer.innerHTML).toContain('Test FAQ'); + expect(resultsContainer.innerHTML).toContain('type="radio"'); + expect(resultsContainer.innerHTML).toContain('value="/faq/1"'); + }); + + it('should clear results when search query is empty', async () => { + injectDialogHtml(); + + commandFn(); + + // First set some content in the results container + const resultsContainer = document.getElementById('pmf-search-results') as HTMLDivElement; + resultsContainer.innerHTML = ''; + + const searchInput = document.getElementById('pmf-search-internal-links') as HTMLInputElement; + searchInput.value = ''; + searchInput.dispatchEvent(new Event('keyup')); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(resultsContainer.innerHTML).toBe(''); + expect(fetchFaqsByAutocomplete).not.toHaveBeenCalled(); + }); + + it('should insert HTML link and close dialog when a radio is selected', () => { + injectDialogHtml(); + + commandFn(); + + // Simulate radio buttons in the results + const resultsContainer = document.getElementById('pmf-search-results') as HTMLDivElement; + resultsContainer.innerHTML = ` +
+ `; + + const selectButton = document.getElementById('select-faq-button') as HTMLButtonElement; + selectButton.click(); + + const insertHTMLFn = (editor.selection as Record).insertHTML; + expect(insertHTMLFn).toHaveBeenCalledWith(expect.stringContaining('/faq/1')); + expect(insertHTMLFn).toHaveBeenCalledWith(expect.stringContaining('Test FAQ')); + expect(insertHTMLFn).toHaveBeenCalledWith(expect.stringContaining(' { + injectDialogHtml(); + + commandFn(); + + const selectButton = document.getElementById('select-faq-button') as HTMLButtonElement; + selectButton.click(); + + expect(alertSpy).toHaveBeenCalledWith('Please select an FAQ.'); + expect(dialog.close).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/statistics/admin-log.test.ts b/phpmyfaq/admin/assets/src/statistics/admin-log.test.ts new file mode 100644 index 0000000000..83065fac02 --- /dev/null +++ b/phpmyfaq/admin/assets/src/statistics/admin-log.test.ts @@ -0,0 +1,530 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleExportAdminLog, handleVerifyAdminLog, handleDeleteAdminLog } from './admin-log'; +import { deleteAdminLog } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api'); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockCreateObjectURL = vi.fn().mockReturnValue('blob:http://localhost/fake-url'); +const mockRevokeObjectURL = vi.fn(); +window.URL.createObjectURL = mockCreateObjectURL; +window.URL.revokeObjectURL = mockRevokeObjectURL; + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('Admin Log Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + describe('handleExportAdminLog', () => { + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
'; + + handleExportAdminLog(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(pushErrorNotification).not.toHaveBeenCalled(); + }); + + it('should show error when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleExportAdminLog(); + + const button = document.getElementById('pmf-export-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Missing CSRF token'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should download file via blob on success', async () => { + document.body.innerHTML = ` + + `; + + const mockBlob = new Blob(['csv,data'], { type: 'text/csv' }); + const mockHeaders = new Headers({ + 'Content-Disposition': 'attachment; filename="admin-log-2024.csv"', + }); + + mockFetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob), + headers: mockHeaders, + }); + + const clickSpy = vi.fn(); + const createElementOriginal = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = createElementOriginal(tag); + if (tag === 'a') { + el.click = clickSpy; + } + return el; + }); + + handleExportAdminLog(); + + const button = document.getElementById('pmf-export-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledWith('/admin/api/statistics/admin-log/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ csrf: 'test-csrf' }), + }); + + expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob); + expect(clickSpy).toHaveBeenCalled(); + expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/fake-url'); + expect(pushNotification).toHaveBeenCalledWith('Admin log exported successfully'); + + vi.restoreAllMocks(); + }); + + it('should use fallback filename when Content-Disposition is missing', async () => { + document.body.innerHTML = ` + + `; + + const mockBlob = new Blob(['csv,data'], { type: 'text/csv' }); + const mockHeaders = new Headers(); + + mockFetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob), + headers: mockHeaders, + }); + + let capturedDownload = ''; + const createElementOriginal = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = createElementOriginal(tag); + if (tag === 'a') { + el.click = vi.fn(); + const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLAnchorElement.prototype, 'download'); + Object.defineProperty(el, 'download', { + set(val: string) { + capturedDownload = val; + if (originalDescriptor && originalDescriptor.set) { + originalDescriptor.set.call(el, val); + } + }, + get() { + return capturedDownload; + }, + }); + } + return el; + }); + + handleExportAdminLog(); + + const button = document.getElementById('pmf-export-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(capturedDownload).toBe('admin-log-export.csv'); + + vi.restoreAllMocks(); + }); + + it('should show error on non-ok response', async () => { + document.body.innerHTML = ` + + `; + + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Server error occurred' }), + }); + + handleExportAdminLog(); + + const button = document.getElementById('pmf-export-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Server error occurred'); + }); + + it('should show fallback error message on non-ok response without error field', async () => { + document.body.innerHTML = ` + + `; + + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({}), + }); + + handleExportAdminLog(); + + const button = document.getElementById('pmf-export-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Export failed'); + }); + + it('should show error on fetch failure', async () => { + document.body.innerHTML = ` + + `; + + mockFetch.mockRejectedValue(new Error('Network failure')); + + handleExportAdminLog(); + + const button = document.getElementById('pmf-export-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Export error: Error: Network failure'); + }); + }); + + describe('handleVerifyAdminLog', () => { + it('should return early when elements are missing', async () => { + document.body.innerHTML = '
'; + + await handleVerifyAdminLog(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should return early when only button exists but result container is missing', async () => { + document.body.innerHTML = ` + + `; + + await handleVerifyAdminLog(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should show success alert when verification is valid', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + verification: { + valid: true, + verified: 100, + total: 100, + }, + }), + }); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement; + expect(resultContainer.className).toBe('alert alert-success'); + expect(resultContainer.innerHTML).toContain('Integrity verified'); + expect(resultContainer.innerHTML).toContain('100 of 100'); + }); + + it('should show danger alert when verification is invalid', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + verification: { + valid: false, + verified: 8, + failed: 2, + errors: ['Entry 5 was tampered', 'Entry 9 hash mismatch'], + }, + }), + }); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement; + expect(resultContainer.className).toBe('alert alert-danger'); + expect(resultContainer.innerHTML).toContain('Entry 5 was tampered'); + expect(resultContainer.innerHTML).toContain('Entry 9 hash mismatch'); + expect(resultContainer.innerHTML).toContain('8'); + expect(resultContainer.innerHTML).toContain('2'); + }); + + it('should show warning alert on error response', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: false, + error: 'Verification service unavailable', + }), + }); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement; + expect(resultContainer.className).toBe('alert alert-warning'); + expect(resultContainer.textContent).toBe('Verification service unavailable'); + }); + + it('should show fallback error message when error field is missing', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: false, + }), + }); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement; + expect(resultContainer.className).toBe('alert alert-warning'); + expect(resultContainer.textContent).toBe('Fehler bei der Verifikation'); + }); + + it('should show error when csrf is missing', async () => { + document.body.innerHTML = ` + +
+ `; + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement; + expect(resultContainer.className).toBe('alert alert-danger'); + expect(resultContainer.textContent).toContain('CSRF Token not found'); + }); + + it('should re-enable button in finally block', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + verification: { + valid: true, + verified: 10, + total: 10, + }, + }), + }); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(button.disabled).toBe(false); + expect(button.innerHTML).toContain('bi-shield-check'); + expect(button.innerHTML).toContain('Integrität prüfen'); + }); + + it('should re-enable button even after fetch failure', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockRejectedValue(new Error('Network failure')); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(button.disabled).toBe(false); + expect(button.innerHTML).toContain('bi-shield-check'); + expect(button.innerHTML).toContain('Integrität prüfen'); + }); + + it('should call fetch with correct URL and headers', async () => { + document.body.innerHTML = ` + +
+ `; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + verification: { valid: true, verified: 5, total: 5 }, + }), + }); + + await handleVerifyAdminLog(); + + const button = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledWith('./api/statistics/admin-log/verify?csrf=my-csrf-token', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + }); + }); + + describe('handleDeleteAdminLog', () => { + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
'; + + handleDeleteAdminLog(); + + expect(deleteAdminLog).not.toHaveBeenCalled(); + expect(pushErrorNotification).not.toHaveBeenCalled(); + }); + + it('should show error when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleDeleteAdminLog(); + + const button = document.getElementById('pmf-delete-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Missing CSRF token'); + expect(deleteAdminLog).not.toHaveBeenCalled(); + }); + + it('should call deleteAdminLog and show notification on success', async () => { + document.body.innerHTML = ` + + `; + + (deleteAdminLog as Mock).mockResolvedValue({ success: 'Admin log deleted' }); + + handleDeleteAdminLog(); + + const button = document.getElementById('pmf-delete-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteAdminLog).toHaveBeenCalledWith('test-csrf'); + expect(pushNotification).toHaveBeenCalledWith('Admin log deleted'); + }); + + it('should show error notification on error response', async () => { + document.body.innerHTML = ` + + `; + + (deleteAdminLog as Mock).mockResolvedValue({ error: 'Deletion failed' }); + + handleDeleteAdminLog(); + + const button = document.getElementById('pmf-delete-admin-log') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteAdminLog).toHaveBeenCalledWith('test-csrf'); + expect(pushErrorNotification).toHaveBeenCalledWith('Deletion failed'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/statistics/admin-log.ts b/phpmyfaq/admin/assets/src/statistics/admin-log.ts index a34450f2bb..698d605fdb 100644 --- a/phpmyfaq/admin/assets/src/statistics/admin-log.ts +++ b/phpmyfaq/admin/assets/src/statistics/admin-log.ts @@ -17,6 +17,120 @@ import { deleteAdminLog } from '../api'; import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; import { Response } from '../interfaces'; +export const handleExportAdminLog = (): void => { + const buttonExportAdminLog = document.getElementById('pmf-export-admin-log') as HTMLButtonElement | null; + + if (buttonExportAdminLog) { + buttonExportAdminLog.addEventListener('click', async (event: Event): Promise => { + event.preventDefault(); + const target = event.currentTarget as HTMLElement; + const csrf = target.getAttribute('data-pmf-csrf'); + + if (!csrf) { + pushErrorNotification('Missing CSRF token'); + return; + } + + try { + const response = await fetch('/admin/api/statistics/admin-log/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ csrf }), + }); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const contentDisposition = response.headers.get('Content-Disposition'); + const filename = contentDisposition + ? contentDisposition.split('filename=')[1]?.replace(/"/g, '') + : 'admin-log-export.csv'; + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + pushNotification('Admin log exported successfully'); + } else { + const errorData = await response.json(); + pushErrorNotification(errorData.error || 'Export failed'); + } + } catch (error) { + pushErrorNotification('Export error: ' + error); + } + }); + } +}; + +export const handleVerifyAdminLog = async (): Promise => { + const verifyButton = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement; + const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement; + + if (!verifyButton || !resultContainer) { + return; + } + + verifyButton.addEventListener('click', async (event: Event) => { + event.preventDefault(); + + verifyButton.disabled = true; + verifyButton.innerHTML = ' Checking...'; + + resultContainer.classList.add('d-none'); + + try { + const csrfToken = verifyButton.dataset.pmfCsrf; + + if (!csrfToken) { + throw new Error('CSRF Token not found'); + } + + const response = await fetch(`./api/statistics/admin-log/verify?csrf=${csrfToken}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + const data = await response.json(); + + resultContainer.classList.remove('d-none'); + + if (data.success && data.verification.valid) { + resultContainer.className = 'alert alert-success'; + resultContainer.innerHTML = ` + + Integrity verified +

${data.verification.verified} of ${data.verification.total} entries successfully checked.

+ `; + } else if (data.success && !data.verification.valid) { + const errors = data.verification.errors.map((err: string) => `
  • ${err}
  • `).join(''); + resultContainer.className = 'alert alert-danger'; + resultContainer.innerHTML = ` + + ⚠️ Manipulation erkannt! +

    ${data.verification.verified} verifiziert, ${data.verification.failed} fehlgeschlagen

    +
      ${errors}
    + `; + } else { + resultContainer.className = 'alert alert-warning'; + resultContainer.textContent = data.error || 'Fehler bei der Verifikation'; + } + } catch (error) { + resultContainer.classList.remove('d-none'); + resultContainer.className = 'alert alert-danger'; + resultContainer.textContent = `Fehler: ${error instanceof Error ? error.message : 'Netzwerkfehler'}`; + } finally { + verifyButton.disabled = false; + verifyButton.innerHTML = ' Integrität prüfen'; + } + }); +}; + export const handleDeleteAdminLog = (): void => { const buttonDeleteAdminLog = document.getElementById('pmf-delete-admin-log') as HTMLButtonElement | null; diff --git a/phpmyfaq/admin/assets/src/statistics/ratings.test.ts b/phpmyfaq/admin/assets/src/statistics/ratings.test.ts new file mode 100644 index 0000000000..43f2ba635a --- /dev/null +++ b/phpmyfaq/admin/assets/src/statistics/ratings.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleClearRatings } from './ratings'; +import { clearRatings } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api', () => ({ + clearRatings: vi.fn(), +})); + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('handleClearRatings', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
    '; + + handleClearRatings(); + + expect(document.body.innerHTML).toBe('
    '); + }); + + it('should show error when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleClearRatings(); + + const button = document.getElementById('pmf-admin-clear-ratings') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Missing CSRF token'); + expect(clearRatings).not.toHaveBeenCalled(); + }); + + it('should call clearRatings and show notification on success', async () => { + document.body.innerHTML = ` + + `; + + (clearRatings as ReturnType).mockResolvedValue({ + success: 'Ratings cleared successfully', + }); + + handleClearRatings(); + + const button = document.getElementById('pmf-admin-clear-ratings') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(clearRatings).toHaveBeenCalledWith('test-csrf-token'); + expect(pushNotification).toHaveBeenCalledWith('Ratings cleared successfully'); + expect(pushErrorNotification).not.toHaveBeenCalled(); + }); + + it('should show error on error response', async () => { + document.body.innerHTML = ` + + `; + + (clearRatings as ReturnType).mockResolvedValue({ + error: 'Failed to clear ratings', + }); + + handleClearRatings(); + + const button = document.getElementById('pmf-admin-clear-ratings') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(clearRatings).toHaveBeenCalledWith('test-csrf-token'); + expect(pushErrorNotification).toHaveBeenCalledWith('Failed to clear ratings'); + expect(pushNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/statistics/report.test.ts b/phpmyfaq/admin/assets/src/statistics/report.test.ts new file mode 100644 index 0000000000..af9bd4abac --- /dev/null +++ b/phpmyfaq/admin/assets/src/statistics/report.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { handleCreateReport } from './report'; +import { createReport } from '../api/export'; +import { pushErrorNotification, serialize } from '../../../../assets/src/utils'; + +vi.mock('../api/export', () => ({ + createReport: vi.fn(), +})); + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushErrorNotification: vi.fn(), + serialize: vi.fn((formData: FormData) => { + const result: Record = {}; + formData.forEach((value, key) => { + result[key] = value.toString(); + }); + return result; + }), + }; +}); + +// Mock URL methods +URL.createObjectURL = vi.fn(() => 'blob:http://localhost/fake-url'); +URL.revokeObjectURL = vi.fn(); + +describe('handleCreateReport', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + // Mock Date for consistent filename + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
    '; + + handleCreateReport(); + + expect(document.body.innerHTML).toBe('
    '); + }); + + it('should show error when no response received', async () => { + document.body.innerHTML = ` +
    + +
    + + + `; + + (createReport as ReturnType).mockResolvedValue(undefined); + + handleCreateReport(); + + const button = document.getElementById('pmf-admin-create-report') as HTMLButtonElement; + button.click(); + + await vi.waitFor(() => { + expect(pushErrorNotification).toHaveBeenCalledWith('No response received'); + }); + }); + + it('should show error on error response', async () => { + document.body.innerHTML = ` +
    + +
    + + + `; + + (createReport as ReturnType).mockResolvedValue({ + error: 'Failed to create report', + }); + + handleCreateReport(); + + const button = document.getElementById('pmf-admin-create-report') as HTMLButtonElement; + button.click(); + + await vi.waitFor(() => { + expect(pushErrorNotification).toHaveBeenCalledWith('Failed to create report'); + }); + }); + + it('should create and download report on success', async () => { + document.body.innerHTML = ` +
    + +
    + + + `; + + const fakeBlob = new Blob(['csv,data'], { type: 'text/csv' }); + (createReport as ReturnType).mockResolvedValue(fakeBlob); + + handleCreateReport(); + + const button = document.getElementById('pmf-admin-create-report') as HTMLButtonElement; + button.click(); + + await vi.waitFor(() => { + expect(createReport).toHaveBeenCalledWith({ testField: 'testValue' }, 'csrf-token'); + expect(URL.createObjectURL).toHaveBeenCalledWith(fakeBlob); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/fake-url'); + expect(pushErrorNotification).not.toHaveBeenCalled(); + }); + }); + + it('should handle form with multiple fields', async () => { + document.body.innerHTML = ` +
    + + + +
    + + + `; + + const fakeBlob = new Blob(['csv,data'], { type: 'text/csv' }); + (createReport as ReturnType).mockResolvedValue(fakeBlob); + + handleCreateReport(); + + const button = document.getElementById('pmf-admin-create-report') as HTMLButtonElement; + button.click(); + + await vi.waitFor(() => { + expect(serialize).toHaveBeenCalled(); + expect(createReport).toHaveBeenCalledWith( + expect.objectContaining({ + field1: 'value1', + field2: 'value2', + field3: 'option1', + }), + 'csrf-token' + ); + }); + }); + + it('should use empty string for csrf when token is missing', async () => { + document.body.innerHTML = ` +
    + +
    + + `; + + const fakeBlob = new Blob(['csv,data'], { type: 'text/csv' }); + (createReport as ReturnType).mockResolvedValue(fakeBlob); + + handleCreateReport(); + + const button = document.getElementById('pmf-admin-create-report') as HTMLButtonElement; + button.click(); + + await vi.waitFor(() => { + expect(createReport).toHaveBeenCalledWith({ testField: 'testValue' }, ''); + }); + }); + + it('should show generic error message when error property is undefined', async () => { + document.body.innerHTML = ` +
    + +
    + + + `; + + (createReport as ReturnType).mockResolvedValue({ + error: undefined, + }); + + handleCreateReport(); + + const button = document.getElementById('pmf-admin-create-report') as HTMLButtonElement; + button.click(); + + await vi.waitFor(() => { + expect(pushErrorNotification).toHaveBeenCalledWith('An error occurred'); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/statistics/report.ts b/phpmyfaq/admin/assets/src/statistics/report.ts index d7211a1dca..532ede5925 100644 --- a/phpmyfaq/admin/assets/src/statistics/report.ts +++ b/phpmyfaq/admin/assets/src/statistics/report.ts @@ -1,5 +1,6 @@ import { createReport } from '../api/export'; import { pushErrorNotification, serialize } from '../../../../assets/src/utils'; +import { Response } from '../interfaces'; export const handleCreateReport = (): void => { const createReportButton = document.getElementById('pmf-admin-create-report') as HTMLButtonElement | null; @@ -10,16 +11,22 @@ export const handleCreateReport = (): void => { const form = document.getElementById('pmf-admin-report-form') as HTMLFormElement; const formData = new FormData(form); + const csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value ?? ''; const serializedData = serialize(formData); - const response = await createReport(serializedData); + const response = await createReport(serializedData, csrfToken); + + if (!response) { + pushErrorNotification('No response received'); + return; + } if ('error' in response) { - pushErrorNotification(response.error); + pushErrorNotification((response as Response).error ?? 'An error occurred'); } else { // Create a download link - const url = window.URL.createObjectURL(response); + const url = window.URL.createObjectURL(response as Blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'phpmyfaq-report-' + new Date().toISOString().substring(0, 10) + '.csv'; diff --git a/phpmyfaq/admin/assets/src/statistics/search.test.ts b/phpmyfaq/admin/assets/src/statistics/search.test.ts new file mode 100644 index 0000000000..ea1365a903 --- /dev/null +++ b/phpmyfaq/admin/assets/src/statistics/search.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleTruncateSearchTerms } from './search'; +import { truncateSearchTerms } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api', () => ({ + truncateSearchTerms: vi.fn(), +})); + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('handleTruncateSearchTerms', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + // Mock window.confirm + global.confirm = vi.fn(); + }); + + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
    '; + + handleTruncateSearchTerms(); + + expect(document.body.innerHTML).toBe('
    '); + }); + + it('should show error when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleTruncateSearchTerms(); + + const button = document.getElementById('pmf-button-truncate-search-terms') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Missing CSRF token'); + expect(truncateSearchTerms).not.toHaveBeenCalled(); + }); + + it('should not truncate when user cancels confirmation', async () => { + document.body.innerHTML = ` + +
    + `; + + (global.confirm as ReturnType).mockReturnValue(false); + + handleTruncateSearchTerms(); + + const button = document.getElementById('pmf-button-truncate-search-terms') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(global.confirm).toHaveBeenCalledWith('Are you sure?'); + expect(truncateSearchTerms).not.toHaveBeenCalled(); + }); + + it('should truncate search terms and remove table on success', async () => { + document.body.innerHTML = ` + +
    + `; + + (global.confirm as ReturnType).mockReturnValue(true); + (truncateSearchTerms as ReturnType).mockResolvedValue({ + success: 'Search terms truncated successfully', + }); + + handleTruncateSearchTerms(); + + const button = document.getElementById('pmf-button-truncate-search-terms') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(global.confirm).toHaveBeenCalledWith('Are you sure?'); + expect(truncateSearchTerms).toHaveBeenCalledWith('test-csrf'); + expect(pushNotification).toHaveBeenCalledWith('Search terms truncated successfully'); + expect(document.getElementById('pmf-table-search-terms')).toBeNull(); + }); + + it('should show error on error response', async () => { + document.body.innerHTML = ` + +
    + `; + + (global.confirm as ReturnType).mockReturnValue(true); + (truncateSearchTerms as ReturnType).mockResolvedValue({ + error: 'Failed to truncate search terms', + }); + + handleTruncateSearchTerms(); + + const button = document.getElementById('pmf-button-truncate-search-terms') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(truncateSearchTerms).toHaveBeenCalledWith('test-csrf'); + expect(pushErrorNotification).toHaveBeenCalledWith('Failed to truncate search terms'); + expect(pushNotification).not.toHaveBeenCalled(); + // Table should still exist + expect(document.getElementById('pmf-table-search-terms')).not.toBeNull(); + }); +}); diff --git a/phpmyfaq/admin/assets/src/statistics/sessions.test.ts b/phpmyfaq/admin/assets/src/statistics/sessions.test.ts new file mode 100644 index 0000000000..9d0412110c --- /dev/null +++ b/phpmyfaq/admin/assets/src/statistics/sessions.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { handleSessionsFilter, handleSessions, handleClearVisits, handleDeleteSessions } from './sessions'; +import { clearVisits, deleteSessions } from '../api'; +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; + +vi.mock('../api', () => ({ + clearVisits: vi.fn(), + deleteSessions: vi.fn(), +})); + +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + pushNotification: vi.fn(), + pushErrorNotification: vi.fn(), + }; +}); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +URL.createObjectURL = vi.fn(() => 'blob:http://localhost/fake-url'); +URL.revokeObjectURL = vi.fn(); + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('handleSessionsFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
    '; + + handleSessionsFilter(); + + expect(document.body.innerHTML).toBe('
    '); + }); + + it('should set form action and submit on click', async () => { + const submitSpy = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {}); + + const timestamp = Math.floor(new Date('2024-06-15T12:00:00Z').getTime() / 1000); + document.body.innerHTML = ` +
    + +
    + + `; + + handleSessionsFilter(); + + const button = document.getElementById('pmf-admin-session-day') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + const form = document.getElementById('pmf-admin-form-session') as HTMLFormElement; + expect(form.action).toContain('/statistics/sessions/2024-06-15'); + expect(submitSpy).toHaveBeenCalled(); + + submitSpy.mockRestore(); + }); +}); + +describe('handleSessions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should do nothing when inputs are missing', () => { + document.body.innerHTML = '
    '; + + handleSessions(); + + expect(document.body.innerHTML).toBe('
    '); + }); + + it('should enable export button when both inputs have values', () => { + document.body.innerHTML = ` + + + + + `; + + handleSessions(); + + const firstHour = document.getElementById('firstHour') as HTMLInputElement; + const lastHour = document.getElementById('lastHour') as HTMLInputElement; + const exportButton = document.getElementById('exportSessions') as HTMLButtonElement; + + expect(exportButton.disabled).toBe(true); + + firstHour.value = '10'; + firstHour.dispatchEvent(new Event('change')); + expect(exportButton.disabled).toBe(true); + + lastHour.value = '20'; + lastHour.dispatchEvent(new Event('change')); + expect(exportButton.disabled).toBe(false); + }); + + it('should disable export button when an input is cleared', () => { + document.body.innerHTML = ` + + + + + `; + + handleSessions(); + + const firstHour = document.getElementById('firstHour') as HTMLInputElement; + const lastHour = document.getElementById('lastHour') as HTMLInputElement; + const exportButton = document.getElementById('exportSessions') as HTMLButtonElement; + + // Initially disabled by handleSessions + expect(exportButton.disabled).toBe(true); + + // Trigger change to enable + lastHour.dispatchEvent(new Event('change')); + expect(exportButton.disabled).toBe(false); + + // Clear firstHour and trigger change on lastHour + firstHour.value = ''; + lastHour.dispatchEvent(new Event('change')); + expect(exportButton.disabled).toBe(true); + }); + + it('should create blob download on successful export', async () => { + document.body.innerHTML = ` + + + + + `; + + const fakeBlob = new Blob(['csv,data'], { type: 'text/csv' }); + mockFetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(fakeBlob), + }); + + handleSessions(); + + const exportButton = document.getElementById('exportSessions') as HTMLButtonElement; + const lastHour = document.getElementById('lastHour') as HTMLInputElement; + + // Enable the export button by triggering the change event + lastHour.dispatchEvent(new Event('change')); + + exportButton.click(); + + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledWith('./api/session/export', { + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + csrf: 'token123', + firstHour: '10', + lastHour: '20', + }), + }); + + expect(URL.createObjectURL).toHaveBeenCalledWith(fakeBlob); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/fake-url'); + }); + + it('should show error notification on non-ok response', async () => { + document.body.innerHTML = ` + + + + + `; + + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Export failed' }), + }); + + handleSessions(); + + const exportButton = document.getElementById('exportSessions') as HTMLButtonElement; + const lastHour = document.getElementById('lastHour') as HTMLInputElement; + + // Enable the export button by triggering change event + lastHour.dispatchEvent(new Event('change')); + + exportButton.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Export failed'); + }); + + it('should log error and show notification on fetch error', async () => { + document.body.innerHTML = ` + + + + + `; + + mockFetch.mockRejectedValue(new Error('Network failure')); + + handleSessions(); + + const exportButton = document.getElementById('exportSessions') as HTMLButtonElement; + const lastHour = document.getElementById('lastHour') as HTMLInputElement; + + // Enable the export button by triggering change event + lastHour.dispatchEvent(new Event('change')); + + exportButton.click(); + + await flushPromises(); + + expect(console.error).toHaveBeenCalledWith('Network failure'); + expect(pushErrorNotification).toHaveBeenCalledWith('Network failure'); + }); +}); + +describe('handleClearVisits', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
    '; + + handleClearVisits(); + + expect(clearVisits).not.toHaveBeenCalled(); + }); + + it('should show error when csrf is missing', async () => { + document.body.innerHTML = ` + + `; + + handleClearVisits(); + + const button = document.getElementById('pmf-admin-clear-visits') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(pushErrorNotification).toHaveBeenCalledWith('Missing CSRF token'); + expect(clearVisits).not.toHaveBeenCalled(); + }); + + it('should call clearVisits and show notification on success', async () => { + document.body.innerHTML = ` + + `; + + (clearVisits as Mock).mockResolvedValue({ success: 'Visits cleared successfully' }); + + handleClearVisits(); + + const button = document.getElementById('pmf-admin-clear-visits') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(clearVisits).toHaveBeenCalledWith('csrf-token-123'); + expect(pushNotification).toHaveBeenCalledWith('Visits cleared successfully'); + }); + + it('should show error on error response', async () => { + document.body.innerHTML = ` + + `; + + (clearVisits as Mock).mockResolvedValue({ error: 'Clear visits failed' }); + + handleClearVisits(); + + const button = document.getElementById('pmf-admin-clear-visits') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(clearVisits).toHaveBeenCalledWith('csrf-token-123'); + expect(pushErrorNotification).toHaveBeenCalledWith('Clear visits failed'); + }); + + it('should show error when no response received', async () => { + document.body.innerHTML = ` + + `; + + (clearVisits as Mock).mockResolvedValue(undefined); + + handleClearVisits(); + + const button = document.getElementById('pmf-admin-clear-visits') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(clearVisits).toHaveBeenCalledWith('csrf-token-123'); + expect(pushErrorNotification).toHaveBeenCalledWith('No response received'); + }); +}); + +describe('handleDeleteSessions', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('should do nothing when button is missing', () => { + document.body.innerHTML = '
    '; + + handleDeleteSessions(); + + expect(deleteSessions).not.toHaveBeenCalled(); + }); + + it('should call deleteSessions with correct params on success', async () => { + document.body.innerHTML = ` + + + + `; + + (deleteSessions as Mock).mockResolvedValue({ success: 'Sessions deleted successfully' }); + + handleDeleteSessions(); + + const button = document.getElementById('pmf-admin-delete-sessions') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteSessions).toHaveBeenCalledWith('csrf-token-456', '2024-06'); + expect(pushNotification).toHaveBeenCalledWith('Sessions deleted successfully'); + }); + + it('should show error on error response', async () => { + document.body.innerHTML = ` + + + + `; + + (deleteSessions as Mock).mockResolvedValue({ error: 'Delete sessions failed' }); + + handleDeleteSessions(); + + const button = document.getElementById('pmf-admin-delete-sessions') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteSessions).toHaveBeenCalledWith('csrf-token-456', '2024-06'); + expect(pushErrorNotification).toHaveBeenCalledWith('Delete sessions failed'); + }); + + it('should show error when no response received', async () => { + document.body.innerHTML = ` + + + + `; + + (deleteSessions as Mock).mockResolvedValue(undefined); + + handleDeleteSessions(); + + const button = document.getElementById('pmf-admin-delete-sessions') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(deleteSessions).toHaveBeenCalledWith('csrf-token-456', '2024-06'); + expect(pushErrorNotification).toHaveBeenCalledWith('No response received'); + }); +}); diff --git a/phpmyfaq/admin/assets/src/statistics/sessions.ts b/phpmyfaq/admin/assets/src/statistics/sessions.ts index b775e5caa2..548551e142 100644 --- a/phpmyfaq/admin/assets/src/statistics/sessions.ts +++ b/phpmyfaq/admin/assets/src/statistics/sessions.ts @@ -15,6 +15,7 @@ import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; import { clearVisits, deleteSessions } from '../api'; +import { Response } from '../interfaces'; export const handleSessionsFilter = (): void => { const button = document.getElementById('pmf-admin-session-day') as HTMLButtonElement | null; @@ -104,7 +105,7 @@ export const handleClearVisits = (): void => { return; } - const response = await clearVisits(csrf); + const response = (await clearVisits(csrf)) as Response | undefined; if (!response) { pushErrorNotification('No response received'); @@ -128,7 +129,7 @@ export const handleDeleteSessions = (): void => { event.preventDefault(); const csrf = (document.getElementById('pmf-csrf-token') as HTMLInputElement).value; const month = (document.getElementById('month') as HTMLInputElement).value; - const response = await deleteSessions(csrf, month); + const response = (await deleteSessions(csrf, month)) as Response | undefined; if (!response) { pushErrorNotification('No response received'); diff --git a/phpmyfaq/admin/assets/src/statistics/statistics.test.ts b/phpmyfaq/admin/assets/src/statistics/statistics.test.ts new file mode 100644 index 0000000000..38bf273186 --- /dev/null +++ b/phpmyfaq/admin/assets/src/statistics/statistics.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleStatistics } from './statistics'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}; + +describe('handleStatistics', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + vi.spyOn(console, 'error').mockImplementation(() => {}); + global.confirm = vi.fn(); + }); + + it('should do nothing when no delete buttons exist', () => { + document.body.innerHTML = '
    '; + + handleStatistics(); + + expect(document.body.innerHTML).toBe('
    '); + }); + + it('should not delete when user cancels confirmation', async () => { + document.body.innerHTML = ` + + `; + + (global.confirm as ReturnType).mockReturnValue(false); + + handleStatistics(); + + const button = document.querySelector('.pmf-delete-search-term') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(global.confirm).toHaveBeenCalledWith('Are you sure?'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should delete search term and remove row on success', async () => { + document.body.innerHTML = ` + + + + +
    + +
    + `; + + (global.confirm as ReturnType).mockReturnValue(true); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ deleted: '123' }), + }); + + handleStatistics(); + + const button = document.querySelector('.pmf-delete-search-term') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(global.confirm).toHaveBeenCalledWith('Are you sure?'); + expect(mockFetch).toHaveBeenCalledWith('./api/search/term', { + method: 'DELETE', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + csrf: 'csrf-token', + searchTermId: '123', + }), + }); + + const row = document.getElementById('row-search-id-123') as HTMLElement; + expect(row).not.toBeNull(); + + // Simulate click and transitionend events + row.click(); + expect(row.style.opacity).toBe('0'); + + row.dispatchEvent(new Event('transitionend')); + expect(document.getElementById('row-search-id-123')).toBeNull(); + }); + + it('should log error on non-ok response', async () => { + document.body.innerHTML = ` + + `; + + (global.confirm as ReturnType).mockReturnValue(true); + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Failed to delete' }), + }); + + handleStatistics(); + + const button = document.querySelector('.pmf-delete-search-term') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(console.error).toHaveBeenCalledWith('Network response was not ok: Failed to delete'); + }); + + it('should log error on fetch failure', async () => { + document.body.innerHTML = ` + + `; + + (global.confirm as ReturnType).mockReturnValue(true); + mockFetch.mockRejectedValue(new Error('Network error')); + + handleStatistics(); + + const button = document.querySelector('.pmf-delete-search-term') as HTMLButtonElement; + button.click(); + + await flushPromises(); + + expect(console.error).toHaveBeenCalledWith('Network error'); + }); + + it('should handle multiple delete buttons', async () => { + document.body.innerHTML = ` + + + `; + + (global.confirm as ReturnType).mockReturnValue(true); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ deleted: '123' }), + }); + + handleStatistics(); + + const buttons = document.querySelectorAll('.pmf-delete-search-term'); + expect(buttons.length).toBe(2); + + (buttons[0] as HTMLButtonElement).click(); + + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledWith('./api/search/term', { + method: 'DELETE', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + csrf: 'csrf-token', + searchTermId: '123', + }), + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/translation/translation-api.test.ts b/phpmyfaq/admin/assets/src/translation/translation-api.test.ts new file mode 100644 index 0000000000..ae68ae4c40 --- /dev/null +++ b/phpmyfaq/admin/assets/src/translation/translation-api.test.ts @@ -0,0 +1,180 @@ +/** + * Translation API Tests + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-17 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TranslationApi } from './translation-api'; +import { TranslationRequest } from './types'; + +describe('TranslationApi', () => { + let api: TranslationApi; + + beforeEach(() => { + api = new TranslationApi(); + vi.clearAllMocks(); + }); + + describe('translate', () => { + it('should translate content and return success response', async () => { + const mockResponse = { + success: true, + translatedFields: { + question: 'Was ist phpMyFAQ?', + answer: 'phpMyFAQ ist ein Open-Source FAQ-System.', + }, + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + const request: TranslationRequest = { + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fields: { + question: 'What is phpMyFAQ?', + answer: 'phpMyFAQ is an open-source FAQ system.', + }, + 'pmf-csrf-token': 'test-token', + }; + + const result = await api.translate(request); + + expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith('/admin/api/translation/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + }); + + it('should return error response when API returns error', async () => { + const mockErrorResponse = { + error: 'Translation provider not configured', + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve(mockErrorResponse), + } as Response) + ); + + const request: TranslationRequest = { + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fields: { question: 'What is phpMyFAQ?' }, + 'pmf-csrf-token': 'test-token', + }; + + const result = await api.translate(request); + + expect(result).toEqual({ + success: false, + error: 'Translation provider not configured', + }); + }); + + it('should return error response when HTTP error occurs', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + } as Response) + ); + + const request: TranslationRequest = { + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fields: { question: 'What is phpMyFAQ?' }, + 'pmf-csrf-token': 'test-token', + }; + + const result = await api.translate(request); + + expect(result).toEqual({ + success: false, + error: 'HTTP error! status: 500', + }); + }); + + it('should return error response when network error occurs', async () => { + const mockError = new Error('Network error'); + global.fetch = vi.fn(() => Promise.reject(mockError)); + + const request: TranslationRequest = { + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fields: { question: 'What is phpMyFAQ?' }, + 'pmf-csrf-token': 'test-token', + }; + + const result = await api.translate(request); + + expect(result).toEqual({ + success: false, + error: 'Network error', + }); + }); + + it('should handle different content types', async () => { + const mockResponse = { + success: true, + translatedFields: { + pageTitle: 'Über uns', + content: 'Dies ist unsere Über-uns-Seite.', + }, + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + const request: TranslationRequest = { + contentType: 'customPage', + sourceLang: 'en', + targetLang: 'de', + fields: { + pageTitle: 'About Us', + content: 'This is our about page.', + }, + 'pmf-csrf-token': 'test-token', + }; + + const result = await api.translate(request); + + expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith('/admin/api/translation/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/translation/translation-api.ts b/phpmyfaq/admin/assets/src/translation/translation-api.ts new file mode 100644 index 0000000000..ff6a1485f5 --- /dev/null +++ b/phpmyfaq/admin/assets/src/translation/translation-api.ts @@ -0,0 +1,61 @@ +/** + * Translation API Client + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-17 + */ + +import { TranslationRequest, TranslationResponse } from './types'; + +/** + * Translation API client + */ +export class TranslationApi { + private readonly apiUrl: string; + + constructor() { + this.apiUrl = '/admin/api/translation/translate'; + } + + /** + * Translate content fields using the configured AI translation provider + * + * @param request - Translation request payload + * @returns Translation response with translated fields or error + */ + async translate(request: TranslationRequest): Promise { + try { + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorData = await response.json(); + return { + success: false, + error: errorData.error || `HTTP error! status: ${response.status}`, + }; + } + + const data = await response.json(); + return data as TranslationResponse; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error occurred', + }; + } + } +} diff --git a/phpmyfaq/admin/assets/src/translation/translator.test.ts b/phpmyfaq/admin/assets/src/translation/translator.test.ts new file mode 100644 index 0000000000..627d8d7fcb --- /dev/null +++ b/phpmyfaq/admin/assets/src/translation/translator.test.ts @@ -0,0 +1,350 @@ +/** + * Translator Tests + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-17 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Translator } from './translator'; +import * as TranslationApiModule from './translation-api'; + +// Mock the TranslationApi module +vi.mock('./translation-api', () => ({ + TranslationApi: vi.fn(), +})); + +// Type for translation result +type TranslationResult = { + success: boolean; + translatedFields?: Record; + error?: string; +}; + +// Helper function to create a mocked TranslationApi +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createMockTranslationApi = (mockTranslate: any) => { + const MockedTranslationApi = vi.mocked(TranslationApiModule.TranslationApi); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + MockedTranslationApi.mockImplementation(function (this: any) { + this.translate = mockTranslate; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); +}; + +describe('Translator', () => { + let button: HTMLButtonElement; + let sourceInput: HTMLInputElement; + let targetInput: HTMLInputElement; + let csrfTokenInput: HTMLInputElement; + + beforeEach(() => { + // Set up DOM + document.body.innerHTML = ` + + + + + `; + + button = document.querySelector('#btn-translate') as HTMLButtonElement; + sourceInput = document.querySelector('#pmf-faq-question') as HTMLInputElement; + targetInput = document.querySelector('#pmf-faq-question-translated') as HTMLInputElement; + csrfTokenInput = document.querySelector('input[name="pmf-csrf-token-translate"]') as HTMLInputElement; + + // Mock console methods and alert to suppress output + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + global.alert = vi.fn(); + + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('should throw error if button not found', () => { + expect(() => { + new Translator({ + buttonSelector: '#nonexistent-button', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + }).toThrow('Button not found: #nonexistent-button'); + }); + + it('should initialize with correct options', () => { + const translator = new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + expect(translator).toBeDefined(); + expect(button.textContent).toBe('Translate with AI'); + }); + + it('should collect source fields correctly', async () => { + const mockTranslate = vi.fn().mockResolvedValue({ + success: true, + translatedFields: { question: 'Was ist phpMyFAQ?' }, + }); + + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockTranslate).toHaveBeenCalledWith({ + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fields: { question: 'What is phpMyFAQ?' }, + 'pmf-csrf-token': 'test-token', + }); + }); + + it('should populate translated fields on success', async () => { + const mockTranslate = vi.fn().mockResolvedValue({ + success: true, + translatedFields: { question: 'Was ist phpMyFAQ?' }, + }); + + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(targetInput.value).toBe('Was ist phpMyFAQ?'); + }); + + it('should set button to loading state during translation', async () => { + let resolveTranslation: ((value: TranslationResult) => void) | undefined; + const translationPromise = new Promise((resolve) => { + resolveTranslation = resolve; + }); + + const mockTranslate = vi.fn().mockReturnValue(translationPromise); + + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Button should be disabled and text changed + expect(button.disabled).toBe(true); + expect(button.textContent).toBe('Translating...'); + + // Resolve the translation + if (resolveTranslation) { + resolveTranslation({ + success: true, + translatedFields: { question: 'Was ist phpMyFAQ?' }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Button should be restored + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Translate with AI'); + }); + + it('should call onTranslationSuccess callback on success', async () => { + const onTranslationSuccess = vi.fn(); + const mockTranslate = vi.fn().mockResolvedValue({ + success: true, + translatedFields: { question: 'Was ist phpMyFAQ?' }, + }); + + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + onTranslationSuccess, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onTranslationSuccess).toHaveBeenCalled(); + }); + + it('should call onTranslationError callback on error', async () => { + const onTranslationError = vi.fn(); + const mockTranslate = vi.fn().mockResolvedValue({ + success: false, + error: 'Translation provider not configured', + }); + + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + onTranslationError, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onTranslationError).toHaveBeenCalledWith('Translation provider not configured'); + }); + + it('should handle missing CSRF token', async () => { + csrfTokenInput.remove(); + + const mockTranslate = vi.fn(); + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockTranslate).not.toHaveBeenCalled(); + expect(global.alert).toHaveBeenCalledWith('Translation failed: CSRF token not found'); + }); + + it('should handle empty fields', async () => { + sourceInput.value = ''; + + const mockTranslate = vi.fn(); + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockTranslate).not.toHaveBeenCalled(); + expect(global.alert).toHaveBeenCalledWith('Translation failed: No fields to translate'); + }); + + it('should prevent multiple simultaneous translations', async () => { + let resolveTranslation: ((value: TranslationResult) => void) | undefined; + const translationPromise = new Promise((resolve) => { + resolveTranslation = resolve; + }); + + const mockTranslate = vi.fn().mockReturnValue(translationPromise); + + createMockTranslationApi(mockTranslate); + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { question: '#pmf-faq-question-translated' }, + }); + + // Click button twice + button.click(); + button.click(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Should only call translate once + expect(mockTranslate).toHaveBeenCalledTimes(1); + + // Resolve the translation + if (resolveTranslation) { + resolveTranslation({ + success: true, + translatedFields: { question: 'Was ist phpMyFAQ?' }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('should handle textarea fields', async () => { + document.body.innerHTML = ` + + + + + `; + + const mockTranslate = vi.fn().mockResolvedValue({ + success: true, + translatedFields: { answer: 'Das ist die Antwort.' }, + }); + + createMockTranslationApi(mockTranslate); + + const translateButton = document.querySelector('#btn-translate') as HTMLButtonElement; + + new Translator({ + buttonSelector: '#btn-translate', + contentType: 'faq', + sourceLang: 'en', + targetLang: 'de', + fieldMapping: { answer: '#pmf-faq-answer-translated' }, + }); + + translateButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const targetTextarea = document.querySelector('#pmf-faq-answer-translated') as HTMLTextAreaElement; + expect(targetTextarea.value).toBe('Das ist die Antwort.'); + }); +}); diff --git a/phpmyfaq/admin/assets/src/translation/translator.ts b/phpmyfaq/admin/assets/src/translation/translator.ts new file mode 100644 index 0000000000..bb0c2b8c88 --- /dev/null +++ b/phpmyfaq/admin/assets/src/translation/translator.ts @@ -0,0 +1,224 @@ +/** + * Main Translation Module + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-17 + */ + +import { TranslationApi } from './translation-api'; +import { ContentType, FieldMapping, TranslatorOptions } from './types'; + +/** + * Translator class for AI-assisted content translation + * + * Usage example: + * ```typescript + * const translator = new Translator({ + * buttonSelector: '#btn-translate-ai', + * contentType: 'faq', + * sourceLang: 'en', + * targetLang: 'de', + * fieldMapping: { + * 'question': '#pmf-faq-question-translated', + * 'answer': '#pmf-faq-answer-translated', + * 'keywords': '#pmf-faq-keywords-translated' + * } + * }); + * ``` + */ +export class Translator { + private readonly api: TranslationApi; + private readonly button: HTMLButtonElement; + private readonly contentType: ContentType; + private readonly sourceLang: string; + private readonly targetLang: string; + private readonly fieldMapping: FieldMapping; + private readonly onTranslationStart?: () => void; + private readonly onTranslationSuccess?: (translatedFields: Record) => void; + private readonly onTranslationError?: (error: string) => void; + + private originalButtonText: string = ''; + private isTranslating: boolean = false; + + constructor(options: TranslatorOptions) { + this.api = new TranslationApi(); + this.contentType = options.contentType; + this.sourceLang = options.sourceLang; + this.targetLang = options.targetLang; + this.fieldMapping = options.fieldMapping; + this.onTranslationStart = options.onTranslationStart; + this.onTranslationSuccess = options.onTranslationSuccess; + this.onTranslationError = options.onTranslationError; + + // Find and configure the button + const buttonElement = document.querySelector(options.buttonSelector); + if (!buttonElement || !(buttonElement instanceof HTMLButtonElement)) { + throw new Error(`Button not found: ${options.buttonSelector}`); + } + this.button = buttonElement; + this.originalButtonText = this.button.textContent || ''; + + // Attach click handler + this.button.addEventListener('click', () => this.handleTranslateClick()); + } + + /** + * Handle translate button click + */ + private async handleTranslateClick(): Promise { + if (this.isTranslating) { + return; + } + + try { + this.isTranslating = true; + this.setButtonLoading(true); + this.onTranslationStart?.(); + + // Collect source field values + const fields = this.collectSourceFields(); + + if (Object.keys(fields).length === 0) { + this.showError('No fields to translate'); + return; + } + + // Get CSRF token + const csrfToken = this.getCsrfToken(); + if (!csrfToken) { + this.showError('CSRF token not found'); + return; + } + + // Call translation API + const response = await this.api.translate({ + contentType: this.contentType, + sourceLang: this.sourceLang, + targetLang: this.targetLang, + fields, + 'pmf-csrf-token': csrfToken, + }); + + if (response.success) { + this.populateTranslatedFields(response.translatedFields); + this.showSuccess(); + this.onTranslationSuccess?.(); + } else { + this.showError(response.error); + this.onTranslationError?.(response.error); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + this.showError(errorMessage); + this.onTranslationError?.(errorMessage); + } finally { + this.isTranslating = false; + this.setButtonLoading(false); + } + } + + /** + * Collect values from source fields + */ + private collectSourceFields(): Record { + const fields: Record = {}; + + for (const fieldName in this.fieldMapping) { + const sourceSelector = `#pmf-${this.contentType}-${fieldName}`; + const sourceElement = document.querySelector(sourceSelector); + + if (sourceElement) { + let value = ''; + + if (sourceElement instanceof HTMLInputElement || sourceElement instanceof HTMLTextAreaElement) { + value = sourceElement.value; + } else if (sourceElement instanceof HTMLElement) { + value = sourceElement.textContent || ''; + } + + if (value.trim()) { + fields[fieldName] = value; + } + } + } + + return fields; + } + + /** + * Populate translated fields into target form elements + */ + private populateTranslatedFields(translatedFields: Record): void { + for (const fieldName in translatedFields) { + const targetSelector = this.fieldMapping[fieldName]; + if (!targetSelector) { + continue; + } + + const targetElement = document.querySelector(targetSelector); + if (!targetElement) { + console.warn(`Target element not found: ${targetSelector}`); + continue; + } + + const translatedValue = translatedFields[fieldName]; + + if (targetElement instanceof HTMLInputElement || targetElement instanceof HTMLTextAreaElement) { + targetElement.value = translatedValue; + + // Trigger input event for any listeners + targetElement.dispatchEvent(new Event('input', { bubbles: true })); + } else if (targetElement instanceof HTMLElement) { + targetElement.textContent = translatedValue; + } + } + } + + /** + * Get CSRF token from the page + */ + private getCsrfToken(): string | null { + const tokenInput = document.querySelector('input[name="pmf-csrf-token-translate"]'); + return tokenInput?.value || null; + } + + /** + * Set the button loading state + */ + private setButtonLoading(loading: boolean): void { + if (loading) { + this.button.disabled = true; + this.button.textContent = 'Translating...'; + this.button.classList.add('disabled'); + } else { + this.button.disabled = false; + this.button.textContent = this.originalButtonText; + this.button.classList.remove('disabled'); + } + } + + /** + * Show a success message + */ + private showSuccess(): void { + // You can customize this to use your app's notification system + console.log('Translation completed successfully'); + } + + /** + * Show error message + */ + private showError(message: string): void { + // You can customize this to use your app's notification system + console.error('Translation failed:', message); + alert(`Translation failed: ${message}`); + } +} diff --git a/phpmyfaq/admin/assets/src/translation/types.ts b/phpmyfaq/admin/assets/src/translation/types.ts new file mode 100644 index 0000000000..8ad2ce1ce0 --- /dev/null +++ b/phpmyfaq/admin/assets/src/translation/types.ts @@ -0,0 +1,72 @@ +/** + * TypeScript types for Translation API + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-17 + */ + +/** + * Content types that can be translated + */ +export type ContentType = 'faq' | 'customPage' | 'category' | 'news'; + +/** + * Field mapping: source field selector -> target field selector + */ +export interface FieldMapping { + [sourceFieldId: string]: string; +} + +/** + * Translation request payload + */ +export interface TranslationRequest { + contentType: ContentType; + sourceLang: string; + targetLang: string; + fields: Record; + 'pmf-csrf-token': string; +} + +/** + * Translation response (success) + */ +export interface TranslationSuccessResponse { + success: true; + translatedFields: Record; +} + +/** + * Translation response (error) + */ +export interface TranslationErrorResponse { + success: false; + error: string; +} + +/** + * Combined translation response type + */ +export type TranslationResponse = TranslationSuccessResponse | TranslationErrorResponse; + +/** + * Translator options + */ +export interface TranslatorOptions { + buttonSelector: string; + contentType: ContentType; + sourceLang: string; + targetLang: string; + fieldMapping: FieldMapping; + onTranslationStart?: () => void; + onTranslationSuccess?: (translatedFields: Record) => void; + onTranslationError?: (error: string) => void; +} diff --git a/phpmyfaq/admin/assets/src/user/users.test.ts b/phpmyfaq/admin/assets/src/user/users.test.ts index 62333dca2a..a11fb4dd10 100644 --- a/phpmyfaq/admin/assets/src/user/users.test.ts +++ b/phpmyfaq/admin/assets/src/user/users.test.ts @@ -56,10 +56,16 @@ vi.mock('../api', () => ({ overwritePassword: vi.fn(), })); -vi.mock('../../../../assets/src/utils', () => ({ - addElement: vi.fn(), - capitalize: vi.fn(), -})); +vi.mock('../../../../assets/src/utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + addElement: vi.fn(), + capitalize: vi.fn((str: string) => str.charAt(0).toUpperCase() + str.slice(1)), + pushErrorNotification: vi.fn(), + pushNotification: vi.fn(), + }; +}); vi.mock('../utils', () => ({ pushErrorNotification: vi.fn(), @@ -100,3 +106,204 @@ describe('User Management Functions', () => { }).not.toThrow(); }); }); + +describe('updateUser', () => { + let updateUser: (userId: string) => Promise; + let fetchUserData: ReturnType; + let fetchUserRights: ReturnType; + + beforeEach(async () => { + const usersModule = await import('./users'); + updateUser = usersModule.updateUser; + const apiModule = await import('../api'); + fetchUserData = apiModule.fetchUserData as ReturnType; + fetchUserRights = apiModule.fetchUserRights as ReturnType; + + document.body.innerHTML = ` + + + + + + + + + + + + + + + + + + + + `; + + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('should update user data and rights', async () => { + const mockUserData = { + userId: '123', + login: 'testuser', + lastModified: '2024-01-01', + authSource: 'local', + status: 'active', + displayName: 'Test User', + email: 'test@example.com', + twoFactorEnabled: false, + isSuperadmin: false, + }; + + fetchUserData.mockResolvedValue(mockUserData); + fetchUserRights.mockResolvedValue(['right1', 'right2']); + + await updateUser('123'); + + expect(fetchUserData).toHaveBeenCalledWith('123'); + expect(fetchUserRights).toHaveBeenCalledWith('123'); + }); + + test('should handle superadmin user correctly', async () => { + const mockUserData = { + userId: '123', + login: 'adminuser', + lastModified: '2024-01-01', + authSource: 'local', + status: 'active', + displayName: 'Admin User', + email: 'admin@example.com', + twoFactorEnabled: false, + isSuperadmin: true, + }; + + fetchUserData.mockResolvedValue(mockUserData); + fetchUserRights.mockResolvedValue([]); + + await updateUser('123'); + + const superAdminCheckbox = document.getElementById('is_superadmin') as HTMLInputElement; + expect(superAdminCheckbox.hasAttribute('checked')).toBe(true); + }); + + test('should handle two-factor enabled user correctly', async () => { + const mockUserData = { + userId: '123', + login: 'testuser', + lastModified: '2024-01-01', + authSource: 'local', + status: 'active', + displayName: 'Test User', + email: 'test@example.com', + twoFactorEnabled: true, + isSuperadmin: false, + }; + + fetchUserData.mockResolvedValue(mockUserData); + fetchUserRights.mockResolvedValue([]); + + await updateUser('123'); + + const twoFactorCheckbox = document.getElementById('overwrite_twofactor') as HTMLInputElement; + expect(twoFactorCheckbox.hasAttribute('checked')).toBe(true); + }); +}); + +describe('handleUsers', () => { + let handleUsers: () => Promise; + + beforeEach(async () => { + const usersModule = await import('./users'); + handleUsers = usersModule.handleUsers; + + document.body.innerHTML = ` + + + + + +
    + + + + + + + + + + + + + + +
    +
    + + + +
    + `; + + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('should handle check all and uncheck all buttons', async () => { + document.body.innerHTML += ` + + + `; + + await handleUsers(); + + const checkAllButton = document.getElementById('checkAll') as HTMLInputElement; + const uncheckAllButton = document.getElementById('uncheckAll') as HTMLInputElement; + + checkAllButton.click(); + document.querySelectorAll('.permission').forEach((checkbox) => { + expect((checkbox as HTMLInputElement).checked).toBe(true); + }); + + uncheckAllButton.click(); + document.querySelectorAll('.permission').forEach((checkbox) => { + expect((checkbox as HTMLInputElement).checked).toBe(false); + }); + }); + + test('should toggle password inputs when automatic password is clicked', async () => { + await handleUsers(); + + const passwordToggle = document.getElementById('add_user_automatic_password') as HTMLInputElement; + const passwordInputs = document.getElementById('add_user_show_password_inputs') as HTMLElement; + + expect(passwordInputs.classList.contains('d-none')).toBe(false); + + passwordToggle.click(); + expect(passwordInputs.classList.contains('d-none')).toBe(true); + + passwordToggle.click(); + expect(passwordInputs.classList.contains('d-none')).toBe(false); + }); + + test('should handle export users button click', async () => { + await handleUsers(); + + const exportButton = document.getElementById('pmf-button-export-users') as HTMLButtonElement; + + // Verify the button exists + expect(exportButton).toBeTruthy(); + + // We cannot test the actual navigation in JSDOM without triggering stderr warnings + // The button click would set window.location.href which is not fully supported in JSDOM + }); +}); diff --git a/phpmyfaq/admin/assets/src/utils/flesch-reading-ease.test.ts b/phpmyfaq/admin/assets/src/utils/flesch-reading-ease.test.ts new file mode 100644 index 0000000000..9f4ca3fcde --- /dev/null +++ b/phpmyfaq/admin/assets/src/utils/flesch-reading-ease.test.ts @@ -0,0 +1,457 @@ +/** + * Unit tests for the Flesch Reading Ease Index Calculator + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-24 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + stripHtml, + countSyllablesEnglish, + countSyllablesGerman, + countSyllablesRomance, + countSyllablesDutch, + countSyllablesSlavic, + countSyllablesNordic, + countSyllablesTurkish, + getSyllableCounter, + countSentences, + getWords, + calculateFleschScore, + getFleschLabel, + analyzeReadability, +} from './flesch-reading-ease'; + +describe('Flesch Reading Ease', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('stripHtml', () => { + it('should remove HTML tags', () => { + expect(stripHtml('

    Hello World

    ')).toBe('Hello World'); + }); + + it('should handle empty content', () => { + expect(stripHtml('')).toBe(''); + }); + + it('should handle nested HTML tags', () => { + expect(stripHtml('

    Test content

    ')).toBe('Test content'); + }); + + it('should handle HTML entities', () => { + expect(stripHtml('

    Hello & World

    ')).toBe('Hello & World'); + }); + }); + + describe('countSyllablesEnglish', () => { + it('should count single syllable words', () => { + expect(countSyllablesEnglish('the')).toBe(1); + expect(countSyllablesEnglish('cat')).toBe(1); + expect(countSyllablesEnglish('dog')).toBe(1); + }); + + it('should count two syllable words', () => { + expect(countSyllablesEnglish('hello')).toBe(2); + expect(countSyllablesEnglish('water')).toBe(2); + }); + + it('should count multi-syllable words', () => { + expect(countSyllablesEnglish('beautiful')).toBeGreaterThanOrEqual(3); + expect(countSyllablesEnglish('computer')).toBeGreaterThanOrEqual(2); + }); + + it('should return 1 for very short words', () => { + expect(countSyllablesEnglish('a')).toBe(1); + expect(countSyllablesEnglish('an')).toBe(1); + expect(countSyllablesEnglish('the')).toBe(1); + }); + + it('should handle empty string', () => { + expect(countSyllablesEnglish('')).toBe(1); + }); + }); + + describe('countSyllablesGerman', () => { + it('should count German syllables with umlauts', () => { + expect(countSyllablesGerman('Übung')).toBe(2); + expect(countSyllablesGerman('Häuser')).toBe(2); + expect(countSyllablesGerman('schön')).toBe(1); + }); + + it('should count simple German words', () => { + expect(countSyllablesGerman('Hund')).toBe(1); + expect(countSyllablesGerman('Katze')).toBe(2); + }); + + it('should return 1 for very short words', () => { + expect(countSyllablesGerman('am')).toBe(1); + expect(countSyllablesGerman('im')).toBe(1); + }); + }); + + describe('countSyllablesRomance', () => { + it('should count Spanish syllables', () => { + expect(countSyllablesRomance('hola')).toBe(2); + expect(countSyllablesRomance('gato')).toBe(2); + expect(countSyllablesRomance('día')).toBeGreaterThanOrEqual(1); // Approximation + }); + + it('should count French syllables', () => { + expect(countSyllablesRomance('bonjour')).toBeGreaterThanOrEqual(2); + expect(countSyllablesRomance('château')).toBeGreaterThanOrEqual(2); // Approximation + }); + + it('should count Italian syllables', () => { + expect(countSyllablesRomance('ciao')).toBeGreaterThanOrEqual(1); + expect(countSyllablesRomance('bello')).toBe(2); + }); + + it('should count Portuguese syllables', () => { + expect(countSyllablesRomance('olá')).toBeGreaterThanOrEqual(1); // Short word + expect(countSyllablesRomance('coração')).toBeGreaterThanOrEqual(2); + }); + }); + + describe('countSyllablesDutch', () => { + it('should count Dutch syllables', () => { + expect(countSyllablesDutch('hallo')).toBe(2); + expect(countSyllablesDutch('goedemorgen')).toBeGreaterThanOrEqual(3); + }); + + it('should return 1 for short words', () => { + expect(countSyllablesDutch('de')).toBe(1); + expect(countSyllablesDutch('het')).toBe(1); + }); + }); + + describe('countSyllablesSlavic', () => { + it('should count Polish syllables', () => { + expect(countSyllablesSlavic('dzień')).toBeGreaterThanOrEqual(1); + expect(countSyllablesSlavic('cześć')).toBeGreaterThanOrEqual(1); + expect(countSyllablesSlavic('polska')).toBeGreaterThanOrEqual(2); + }); + + it('should count Russian syllables (Cyrillic)', () => { + expect(countSyllablesSlavic('привет')).toBeGreaterThanOrEqual(2); + expect(countSyllablesSlavic('да')).toBe(1); + }); + }); + + describe('countSyllablesNordic', () => { + it('should count Swedish syllables', () => { + expect(countSyllablesNordic('hej')).toBe(1); + expect(countSyllablesNordic('välkommen')).toBeGreaterThanOrEqual(2); + }); + + it('should count Norwegian/Danish syllables', () => { + expect(countSyllablesNordic('hei')).toBe(1); + expect(countSyllablesNordic('takk')).toBe(1); + }); + + it('should handle Nordic special characters', () => { + expect(countSyllablesNordic('öl')).toBe(1); + expect(countSyllablesNordic('fågel')).toBe(2); + }); + }); + + describe('countSyllablesTurkish', () => { + it('should count Turkish syllables', () => { + expect(countSyllablesTurkish('merhaba')).toBeGreaterThanOrEqual(2); + expect(countSyllablesTurkish('güzel')).toBe(2); + }); + + it('should handle Turkish special characters', () => { + expect(countSyllablesTurkish('öğrenci')).toBeGreaterThanOrEqual(2); + }); + }); + + describe('getSyllableCounter', () => { + it('should return correct counter for each language', () => { + expect(getSyllableCounter('en')).toBe(countSyllablesEnglish); + expect(getSyllableCounter('de')).toBe(countSyllablesGerman); + expect(getSyllableCounter('es')).toBe(countSyllablesRomance); + expect(getSyllableCounter('fr')).toBe(countSyllablesRomance); + expect(getSyllableCounter('it')).toBe(countSyllablesRomance); + expect(getSyllableCounter('pt')).toBe(countSyllablesRomance); + expect(getSyllableCounter('nl')).toBe(countSyllablesDutch); + expect(getSyllableCounter('pl')).toBe(countSyllablesSlavic); + expect(getSyllableCounter('ru')).toBe(countSyllablesSlavic); + expect(getSyllableCounter('cs')).toBe(countSyllablesSlavic); + expect(getSyllableCounter('sv')).toBe(countSyllablesNordic); + expect(getSyllableCounter('da')).toBe(countSyllablesNordic); + expect(getSyllableCounter('no')).toBe(countSyllablesNordic); + expect(getSyllableCounter('fi')).toBe(countSyllablesNordic); + expect(getSyllableCounter('tr')).toBe(countSyllablesTurkish); + }); + }); + + describe('countSentences', () => { + it('should count sentences ending with period', () => { + expect(countSentences('Hello. World.')).toBe(2); + expect(countSentences('One sentence.')).toBe(1); + }); + + it('should count sentences ending with different punctuation', () => { + expect(countSentences('Hello! World?')).toBe(2); + expect(countSentences('Really?! Yes.')).toBe(2); + }); + + it('should return 1 for text without punctuation', () => { + expect(countSentences('No punctuation here')).toBe(1); + }); + + it('should handle multiple consecutive punctuation marks', () => { + expect(countSentences('What?! Yes...')).toBe(2); + }); + }); + + describe('getWords', () => { + it('should extract words from text', () => { + const words = getWords('Hello World'); + expect(words).toEqual(['Hello', 'World']); + }); + + it('should remove punctuation', () => { + const words = getWords('Hello, World!'); + expect(words).toEqual(['Hello', 'World']); + }); + + it('should handle German umlauts', () => { + const words = getWords('Häuser und Übungen'); + expect(words).toEqual(['Häuser', 'und', 'Übungen']); + }); + + it('should handle French accents', () => { + const words = getWords('café résumé'); + expect(words).toEqual(['café', 'résumé']); + }); + + it('should handle Spanish characters', () => { + const words = getWords('niño mañana'); + expect(words).toEqual(['niño', 'mañana']); + }); + + it('should handle Russian Cyrillic', () => { + const words = getWords('привет мир'); + expect(words).toEqual(['привет', 'мир']); + }); + + it('should return empty array for empty text', () => { + expect(getWords('')).toEqual([]); + }); + + it('should handle multiple spaces', () => { + const words = getWords('Hello World'); + expect(words).toEqual(['Hello', 'World']); + }); + }); + + describe('calculateFleschScore', () => { + it('should return 0 for empty text', () => { + expect(calculateFleschScore('')).toBe(0); + }); + + it('should return 0 for text with only punctuation', () => { + expect(calculateFleschScore('...')).toBe(0); + }); + + it('should calculate English score for simple text', () => { + const text = 'The cat sat on the mat. It was a nice day.'; + const score = calculateFleschScore(text, 'en'); + expect(score).toBeGreaterThan(60); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate German score', () => { + const text = 'Die Katze sitzt auf der Matte. Es war ein schöner Tag.'; + const score = calculateFleschScore(text, 'de'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Spanish score', () => { + const text = 'El gato se sienta en la alfombra. Era un buen día.'; + const score = calculateFleschScore(text, 'es'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate French score', () => { + const text = 'Le chat est assis sur le tapis. Il fait beau.'; + const score = calculateFleschScore(text, 'fr'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Italian score', () => { + const text = 'Il gatto è seduto sul tappeto. Era una bella giornata.'; + const score = calculateFleschScore(text, 'it'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Dutch score', () => { + const text = 'De kat zit op de mat. Het was een mooie dag.'; + const score = calculateFleschScore(text, 'nl'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Portuguese score', () => { + const text = 'O gato está sentado no tapete. Foi um bom dia.'; + const score = calculateFleschScore(text, 'pt'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Russian score', () => { + const text = 'Кот сидит на коврике. Это был хороший день.'; + const score = calculateFleschScore(text, 'ru'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Swedish score', () => { + const text = 'Katten sitter på mattan. Det var en fin dag.'; + const score = calculateFleschScore(text, 'sv'); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should calculate Turkish score', () => { + const text = 'Kedi evde. Güzel gün.'; // Simple short sentences + const score = calculateFleschScore(text, 'tr'); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(100); + }); + + it('should clamp scores to 0-100 range', () => { + const simpleText = 'Go. Run. Jump.'; + const score = calculateFleschScore(simpleText, 'en'); + expect(score).toBeLessThanOrEqual(100); + expect(score).toBeGreaterThanOrEqual(0); + }); + + it('should return lower score for complex text', () => { + const complexText = + 'The implementation of sophisticated algorithmic methodologies necessitates comprehensive understanding of computational paradigms.'; + const simpleText = 'The cat sat on the mat.'; + + const complexScore = calculateFleschScore(complexText, 'en'); + const simpleScore = calculateFleschScore(simpleText, 'en'); + + expect(complexScore).toBeLessThan(simpleScore); + }); + + it('should default to English formula', () => { + const text = 'Hello world.'; + const defaultScore = calculateFleschScore(text); + const englishScore = calculateFleschScore(text, 'en'); + expect(defaultScore).toBe(englishScore); + }); + }); + + describe('getFleschLabel', () => { + it('should return Very Easy for scores 90-100', () => { + expect(getFleschLabel(95).label).toBe('Very Easy'); + expect(getFleschLabel(90).label).toBe('Very Easy'); + expect(getFleschLabel(100).label).toBe('Very Easy'); + }); + + it('should return Easy for scores 80-89', () => { + expect(getFleschLabel(85).label).toBe('Easy'); + expect(getFleschLabel(80).label).toBe('Easy'); + }); + + it('should return Fairly Easy for scores 70-79', () => { + expect(getFleschLabel(75).label).toBe('Fairly Easy'); + expect(getFleschLabel(70).label).toBe('Fairly Easy'); + }); + + it('should return Standard for scores 60-69', () => { + expect(getFleschLabel(65).label).toBe('Standard'); + expect(getFleschLabel(60).label).toBe('Standard'); + }); + + it('should return Fairly Difficult for scores 50-59', () => { + expect(getFleschLabel(55).label).toBe('Fairly Difficult'); + expect(getFleschLabel(50).label).toBe('Fairly Difficult'); + }); + + it('should return Difficult for scores 30-49', () => { + expect(getFleschLabel(40).label).toBe('Difficult'); + expect(getFleschLabel(30).label).toBe('Difficult'); + }); + + it('should return Very Difficult for scores 0-29', () => { + expect(getFleschLabel(20).label).toBe('Very Difficult'); + expect(getFleschLabel(0).label).toBe('Very Difficult'); + }); + + it('should return correct color classes', () => { + expect(getFleschLabel(90).colorClass).toBe('success'); + expect(getFleschLabel(80).colorClass).toBe('success'); + expect(getFleschLabel(70).colorClass).toBe('info'); + expect(getFleschLabel(60).colorClass).toBe('primary'); + expect(getFleschLabel(50).colorClass).toBe('warning'); + expect(getFleschLabel(30).colorClass).toBe('warning'); + expect(getFleschLabel(20).colorClass).toBe('danger'); + }); + }); + + describe('analyzeReadability', () => { + it('should return complete result object', () => { + const result = analyzeReadability('Simple text. Easy to read.', 'en'); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('label'); + expect(result).toHaveProperty('colorClass'); + }); + + it('should return numeric score', () => { + const result = analyzeReadability('Hello world.', 'en'); + expect(typeof result.score).toBe('number'); + }); + + it('should return string label', () => { + const result = analyzeReadability('Hello world.', 'en'); + expect(typeof result.label).toBe('string'); + }); + + it('should return valid color class', () => { + const result = analyzeReadability('Hello world.', 'en'); + const validClasses = ['success', 'info', 'primary', 'warning', 'danger']; + expect(validClasses).toContain(result.colorClass); + }); + + it('should handle German text', () => { + const result = analyzeReadability('Hallo Welt.', 'de'); + expect(result.score).toBeGreaterThanOrEqual(0); + }); + + it('should handle Spanish text', () => { + const result = analyzeReadability('Hola mundo.', 'es'); + expect(result.score).toBeGreaterThanOrEqual(0); + }); + + it('should handle French text', () => { + const result = analyzeReadability('Bonjour monde.', 'fr'); + expect(result.score).toBeGreaterThanOrEqual(0); + }); + + it('should handle empty text', () => { + const result = analyzeReadability('', 'en'); + expect(result.score).toBe(0); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/utils/flesch-reading-ease.ts b/phpmyfaq/admin/assets/src/utils/flesch-reading-ease.ts new file mode 100644 index 0000000000..b3f295ce04 --- /dev/null +++ b/phpmyfaq/admin/assets/src/utils/flesch-reading-ease.ts @@ -0,0 +1,318 @@ +/** + * Flesch Reading Ease Index Calculator + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-24 + */ + +export interface FleschResult { + score: number; + label: string; + colorClass: string; +} + +/** + * Supported languages for Flesch Reading Ease calculation + * Each language has its own adapted formula + */ +export type SupportedLanguage = + | 'de' // German + | 'en' // English + | 'es' // Spanish + | 'fr' // French + | 'it' // Italian + | 'nl' // Dutch + | 'pt' // Portuguese + | 'pl' // Polish + | 'ru' // Russian + | 'cs' // Czech + | 'tr' // Turkish + | 'sv' // Swedish + | 'da' // Danish + | 'no' // Norwegian + | 'fi'; // Finnish + +/** + * Strips HTML tags from content to get plain text + */ +export const stripHtml = (html: string): string => { + const temp = document.createElement('div'); + temp.innerHTML = html; + return temp.textContent || temp.innerText || ''; +}; + +/** + * Counts syllables in a word (English approximation) + * Uses vowel group counting with common adjustments + */ +export const countSyllablesEnglish = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // Remove trailing silent 'e' + word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); + word = word.replace(/^y/, ''); + + const syllables = word.match(/[aeiouy]{1,2}/g); + return syllables ? syllables.length : 1; +}; + +/** + * Counts syllables in a word (German approximation) + * German syllables are based on vowel patterns including umlauts + */ +export const countSyllablesGerman = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // German vowels including umlauts + const syllables = word.match(/[aeiouäöü]{1,2}/g); + return syllables ? Math.max(syllables.length, 1) : 1; +}; + +/** + * Counts syllables for Romance languages (Spanish, French, Italian, Portuguese) + * These languages have similar vowel patterns + */ +export const countSyllablesRomance = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // Romance language vowels including accented characters + const syllables = word.match(/[aeiouáéíóúàèìòùâêîôûäëïöüãõ]{1,2}/g); + return syllables ? Math.max(syllables.length, 1) : 1; +}; + +/** + * Counts syllables for Dutch + */ +export const countSyllablesDutch = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // Dutch vowels including common digraphs + const syllables = word.match(/[aeiouéèëïöü]{1,2}/g); + return syllables ? Math.max(syllables.length, 1) : 1; +}; + +/** + * Counts syllables for Slavic languages (Polish, Czech, Russian) + */ +export const countSyllablesSlavic = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // Slavic vowels: Polish (a,e,i,o,u,y,ą,ę,ó), Russian (а,е,ё,и,о,у,ы,э,ю,я), Czech (a,e,i,o,u,y,á,é,í,ó,ú,ů,ý) + const syllables = word.match(/[aeiouyąęóáéíúůýаеёиоуыэюя]{1,2}/gi); + return syllables ? Math.max(syllables.length, 1) : 1; +}; + +/** + * Counts syllables for Nordic languages (Swedish, Danish, Norwegian, Finnish) + */ +export const countSyllablesNordic = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // Nordic vowels including special characters + const syllables = word.match(/[aeiouäöåæø]{1,2}/g); + return syllables ? Math.max(syllables.length, 1) : 1; +}; + +/** + * Counts syllables for Turkish + */ +export const countSyllablesTurkish = (word: string): number => { + word = word.toLowerCase().trim(); + if (word.length <= 3) { + return 1; + } + + // Turkish vowels including special characters + const syllables = word.match(/[aeiouöüıİ]{1,2}/gi); + return syllables ? Math.max(syllables.length, 1) : 1; +}; + +/** + * Gets the appropriate syllable counter for a language + */ +export const getSyllableCounter = (language: SupportedLanguage): ((word: string) => number) => { + switch (language) { + case 'de': + return countSyllablesGerman; + case 'es': + case 'fr': + case 'it': + case 'pt': + return countSyllablesRomance; + case 'nl': + return countSyllablesDutch; + case 'pl': + case 'cs': + case 'ru': + return countSyllablesSlavic; + case 'sv': + case 'da': + case 'no': + case 'fi': + return countSyllablesNordic; + case 'tr': + return countSyllablesTurkish; + case 'en': + default: + return countSyllablesEnglish; + } +}; + +/** + * Counts sentences in text + * Handles multiple punctuation marks + */ +export const countSentences = (text: string): number => { + const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0); + return Math.max(sentences.length, 1); +}; + +/** + * Extracts words from text, supporting multiple character sets + */ +export const getWords = (text: string): string[] => { + return text + .replace( + /[^a-zA-ZäöüÄÖÜßáéíóúàèìòùâêîôûãõçñąęółńśźżćčďěňřšťůýžæøåаеёиоуыэюяабвгдежзийклмнопрстуфхцчшщъьıİğş\s]/gi, + ' ' + ) + .split(/\s+/) + .filter((word) => word.length > 0); +}; + +/** + * Language-specific Flesch formulas + * + * Sources: + * - English (Flesch): 206.835 - (1.015 × ASL) - (84.6 × ASW) + * - German (Amstad): 180 - ASL - (58.5 × ASW) + * - Spanish (Fernández-Huerta): 206.84 - (1.02 × ASL) - (60 × ASW) + * - French: 207 - (1.015 × ASL) - (73.6 × ASW) + * - Italian (Franchina-Vacca): 217 - (1.3 × ASL) - (60 × ASW) + * - Dutch (Douma): 206.84 - (0.93 × ASL) - (77 × ASW) + * - Portuguese: 248.835 - (1.015 × ASL) - (84.6 × ASW) + * - Polish: 206.835 - (1.015 × ASL) - (84.6 × ASW) (adapted) + * - Russian: 206.835 - (1.3 × ASL) - (60.1 × ASW) (adapted) + * - Czech: 206.835 - (1.015 × ASL) - (84.6 × ASW) (adapted) + * - Turkish: 198.825 - (1.015 × ASL) - (84.6 × ASW) (adapted) + * - Swedish (LIX-based approximation): 200 - (ASL) - (68 × ASW) + * - Danish: 206.835 - (1.015 × ASL) - (84.6 × ASW) (adapted) + * - Norwegian: 206.835 - (1.015 × ASL) - (84.6 × ASW) (adapted) + * - Finnish: 206.835 - (1.015 × ASL) - (84.6 × ASW) (adapted) + */ +interface FleschFormula { + base: number; + aslCoefficient: number; + aswCoefficient: number; +} + +const fleschFormulas: Record = { + en: { base: 206.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + de: { base: 180, aslCoefficient: 1, aswCoefficient: 58.5 }, + es: { base: 206.84, aslCoefficient: 1.02, aswCoefficient: 60 }, + fr: { base: 207, aslCoefficient: 1.015, aswCoefficient: 73.6 }, + it: { base: 217, aslCoefficient: 1.3, aswCoefficient: 60 }, + nl: { base: 206.84, aslCoefficient: 0.93, aswCoefficient: 77 }, + pt: { base: 248.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + pl: { base: 206.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + ru: { base: 206.835, aslCoefficient: 1.3, aswCoefficient: 60.1 }, + cs: { base: 206.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + tr: { base: 198.825, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + sv: { base: 200, aslCoefficient: 1, aswCoefficient: 68 }, + da: { base: 206.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + no: { base: 206.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, + fi: { base: 206.835, aslCoefficient: 1.015, aswCoefficient: 84.6 }, +}; + +/** + * Calculates Flesch Reading Ease score using language-specific formulas + * + * Formula: base - (aslCoefficient × ASL) - (aswCoefficient × ASW) + * + * Where: + * - ASL = Average Sentence Length (words per sentence) + * - ASW = Average Syllables per Word + */ +export const calculateFleschScore = (text: string, language: SupportedLanguage = 'en'): number => { + const plainText = stripHtml(text); + const words = getWords(plainText); + const wordCount = words.length; + + if (wordCount === 0) { + return 0; + } + + const sentenceCount = countSentences(plainText); + const syllableCounter = getSyllableCounter(language); + const totalSyllables = words.reduce((sum, word) => sum + syllableCounter(word), 0); + + const averageSentenceLength = wordCount / sentenceCount; + const averageSyllablesPerWord = totalSyllables / wordCount; + + const formula = fleschFormulas[language] || fleschFormulas.en; + const score = + formula.base - formula.aslCoefficient * averageSentenceLength - formula.aswCoefficient * averageSyllablesPerWord; + + // Clamp score to 0-100 range + return Math.max(0, Math.min(100, Math.round(score * 10) / 10)); +}; + +/** + * Gets human-readable label and color class for a Flesch score + */ +export const getFleschLabel = (score: number): { label: string; colorClass: string } => { + if (score >= 90) { + return { label: 'Very Easy', colorClass: 'success' }; + } + if (score >= 80) { + return { label: 'Easy', colorClass: 'success' }; + } + if (score >= 70) { + return { label: 'Fairly Easy', colorClass: 'info' }; + } + if (score >= 60) { + return { label: 'Standard', colorClass: 'primary' }; + } + if (score >= 50) { + return { label: 'Fairly Difficult', colorClass: 'warning' }; + } + if (score >= 30) { + return { label: 'Difficult', colorClass: 'warning' }; + } + return { label: 'Very Difficult', colorClass: 'danger' }; +}; + +/** + * Main function to calculate Flesch Reading Ease with a full result + */ +export const analyzeReadability = (text: string, language: SupportedLanguage = 'en'): FleschResult => { + const score = calculateFleschScore(text, language); + const { label, colorClass } = getFleschLabel(score); + return { score, label, colorClass }; +}; diff --git a/phpmyfaq/admin/assets/src/utils/index.ts b/phpmyfaq/admin/assets/src/utils/index.ts index 964a9bda98..e57455bed1 100644 --- a/phpmyfaq/admin/assets/src/utils/index.ts +++ b/phpmyfaq/admin/assets/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './flesch-reading-ease'; export * from './session'; export * from './sidebar'; export * from './utils'; diff --git a/phpmyfaq/admin/index.php b/phpmyfaq/admin/index.php index 0aa88f6f97..606e0a0ce8 100755 --- a/phpmyfaq/admin/index.php +++ b/phpmyfaq/admin/index.php @@ -20,11 +20,21 @@ */ use phpMyFAQ\Application; +use phpMyFAQ\Controller\Frontend\ErrorController; +use phpMyFAQ\Core\Exception\DatabaseConnectionException; +use phpMyFAQ\Environment; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; -require dirname(__DIR__) . '/src/Bootstrap.php'; +try { + require dirname(__DIR__) . '/src/Bootstrap.php'; +} catch (DatabaseConnectionException $databaseConnectionException) { + $errorMessage = Environment::isDebugMode() ? $databaseConnectionException->getMessage() : null; + $response = ErrorController::renderBootstrapError($errorMessage); + $response->send(); + exit(1); +} // // Service Containers @@ -37,11 +47,11 @@ echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); } -$routes = include PMF_SRC_DIR . '/admin-routes.php'; - $app = new Application($container); +$app->routingContext = 'admin'; try { - $app->run($routes); + // Auto-loads routes from attributes (falls back to admin-routes.php during migration) + $app->run(); } catch (Exception $exception) { echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); } diff --git a/phpmyfaq/api/index.php b/phpmyfaq/api/index.php index cdc23ec778..ba5ad6add7 100644 --- a/phpmyfaq/api/index.php +++ b/phpmyfaq/api/index.php @@ -1,7 +1,7 @@ getMessage() + : 'The database server is currently unavailable. Please try again later.'; -require '../src/Bootstrap.php'; + $problemDetails = [ + 'type' => '/problems/database-unavailable', + 'title' => 'Database Connection Error', + 'status' => Response::HTTP_INTERNAL_SERVER_ERROR, + 'detail' => $errorMessage, + 'instance' => $_SERVER['REQUEST_URI'] ?? '/api', + ]; + + $response = new JsonResponse( + data: $problemDetails, + status: Response::HTTP_INTERNAL_SERVER_ERROR, + headers: ['Content-Type' => 'application/problem+json'] + ); + $response->send(); + exit(1); +} // // Service Containers @@ -33,11 +61,12 @@ echo $e->getMessage(); } -$routes = include PMF_SRC_DIR . '/api-routes.php'; - $app = new Application($container); +$app->setApiContext(true); +$app->routingContext = 'api'; try { - $app->run($routes); + // Autoload routes from attributes (falls back to api-routes.php during migration) + $app->run(); } catch (Exception $exception) { echo $exception->getMessage(); } diff --git a/phpmyfaq/ask.php b/phpmyfaq/ask.php deleted file mode 100644 index 8573aa517d..0000000000 --- a/phpmyfaq/ask.php +++ /dev/null @@ -1,99 +0,0 @@ - - * @copyright 2002-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2002-09-17 - */ - -use phpMyFAQ\Category; -use phpMyFAQ\Enums\Forms\FormIds; -use phpMyFAQ\Filter; -use phpMyFAQ\Forms; -use phpMyFAQ\Twig\TwigWrapper; -use phpMyFAQ\Translation; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Twig\TwigFilter; - -if (!defined('IS_VALID_PHPMYFAQ')) { - http_response_code(400); - exit(); -} - -$request = Request::createFromGlobals(); - -$faqConfig = $container->get('phpmyfaq.configuration'); -$user = $container->get('phpmyfaq.user.current_user'); - -$faqSession = $container->get('phpmyfaq.user.session'); -$faqSession->setCurrentUser($user); - -// Check user permissions -if ((-1 === $user->getUserId() && !$faqConfig->get('records.allowQuestionsForGuests'))) { - $response = new RedirectResponse($faqConfig->getDefaultUrl() . 'login'); - $response->send(); -} - -$captcha = $container->get('phpmyfaq.captcha'); - -$faqSession->userTracking('ask_question', 0); - -$category = new Category($faqConfig, $currentGroups); -$category->transform(0); -$category->buildCategoryTree(); - -$categoryId = Filter::filterVar($request->query->get('category_id'), FILTER_VALIDATE_INT, 0); - -$captchaHelper = $container->get('phpmyfaq.captcha.helper.captcha_helper'); - -$forms = new Forms($faqConfig); -$formData = $forms->getFormData(FormIds::ASK_QUESTION->value); - -$categories = $category->getAllCategoryIds(); - -$twig = new TwigWrapper(PMF_ROOT_DIR . '/assets/templates/'); -$twig->addFilter(new TwigFilter('repeat', fn($string, $times): string => str_repeat((string) $string, $times))); -$twigTemplate = $twig->loadTemplate('./ask.twig'); - -$templateVars = [ - ... $templateVars, - 'title' => sprintf('%s - %s', Translation::get(key: 'msgQuestion'), $faqConfig->getTitle()), - 'metaDescription' => sprintf(Translation::get(key: 'msgQuestionMetaDesc'), $faqConfig->getTitle()), - 'msgMatchingQuestions' => Translation::get(key: 'msgMatchingQuestions'), - 'msgFinishSubmission' => Translation::get(key: 'msgFinishSubmission'), - 'lang' => $Language->getLanguage(), - 'defaultContentMail' => ($user->getUserId() > 0) ? $user->getUserData('email') : '', - 'defaultContentName' => ($user->getUserId() > 0) ? $user->getUserData('display_name') : '', - 'selectedCategory' => $categoryId, - 'categories' => $category->getCategoryTree(), - 'captchaFieldset' => - $captchaHelper->renderCaptcha($captcha, 'ask', Translation::get(key: 'msgCaptcha'), $user->isLoggedIn()), - 'msgNewContentSubmit' => Translation::get(key: 'msgNewContentSubmit'), - 'noCategories' => $categories === [], - 'msgFormDisabledDueToMissingCategories' => Translation::get(key: 'msgFormDisabledDueToMissingCategories') -]; - -// Collect data for displaying form -foreach ($formData as $input) { - if ((int)$input->input_active !== 0) { - $label = sprintf('id%d_label', (int)$input->input_id); - $required = sprintf('id%d_required', (int)$input->input_id); - $templateVars = [ - ...$templateVars, - $label => $input->input_label, - $required => ((int)$input->input_required !== 0) ? 'required' : '' - ]; - } -} - -return $templateVars; diff --git a/phpmyfaq/assets/scss/layout/_cookie-custom.scss b/phpmyfaq/assets/scss/layout/_cookie-custom.scss new file mode 100644 index 0000000000..a9b98c8649 --- /dev/null +++ b/phpmyfaq/assets/scss/layout/_cookie-custom.scss @@ -0,0 +1,179 @@ +// Cookie Consent Theme Integration + +[data-bs-theme='light'] { + #cc-main { + .cm, .pm { + border: 2px solid var(--cc-secondary-color); + box-shadow: 5px 5px 15px var(--cc-secondary-color); + } + } +} + +[data-bs-theme='dark'] { + --cc-bg: #161a1c; + --cc-primary-color: #ebf3f6; + --cc-secondary-color: #aebbc5; + --cc-btn-primary-bg: #c2d0e0; + --cc-btn-primary-color: var(--cc-bg); + --cc-btn-primary-border-color: var(--cc-btn-primary-bg); + --cc-btn-primary-hover-bg: #98a7b6; + --cc-btn-primary-hover-color: #000000; + --cc-btn-primary-hover-border-color: var(--cc-btn-primary-hover-bg); + --cc-btn-secondary-bg: #242c31; + --cc-btn-secondary-color: var(--cc-primary-color); + --cc-btn-secondary-border-color: var(--cc-btn-secondary-bg); + --cc-btn-secondary-hover-bg: #353d43; + --cc-btn-secondary-hover-color: #ffffff; + --cc-btn-secondary-hover-border-color: var(--cc-btn-secondary-hover-bg); + --cc-separator-border-color: #222a30; + --cc-toggle-on-bg: var(--cc-btn-primary-bg); + --cc-toggle-off-bg: #525f6b; + --cc-toggle-on-knob-bg: var(--cc-btn-primary-color); + --cc-toggle-off-knob-bg: var(--cc-btn-primary-color); + --cc-toggle-enabled-icon-color: var(--cc-btn-primary-color); + --cc-toggle-disabled-icon-color: var(--cc-btn-primary-color); + --cc-toggle-readonly-bg: #343e45; + --cc-toggle-readonly-knob-bg: #5f6b72; + --cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg); + --cc-section-category-border: #1e2428; + --cc-cookie-category-block-bg: #1e2428; + --cc-cookie-category-block-border: var(--cc-section-category-border); + --cc-cookie-category-block-hover-bg: #242c31; + --cc-cookie-category-block-hover-border: #232a2f; + --cc-cookie-category-expanded-block-bg: transparent; + --cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg); + --cc-overlay-bg: rgba(0, 0, 0, 0.65); + --cc-webkit-scrollbar-bg: var(--cc-section-category-border); + --cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg); + --cc-footer-bg: #0c0e0f; + --cc-footer-color: var(--cc-secondary-color); + --cc-footer-border-color: #060809; + + #cc-main { + .cm, .pm { + border: 2px solid var(--cc-separator-border-color); + box-shadow: 5px 5px 15px var(--cc-separator-border-color); + } + } +} + +[data-bs-theme='high-contrast'] { + --cc-font-family: 'Atkinson Hyperlegible Next', sans-serif; + --cc-bg: var(--bs-primary) !important; + --cc-text: var(--bs-dark) !important; + --cc-btn-primary-bg: var(--bs-dark); + --cc-btn-primary-color: var(--bs-primary); + --cc-btn-primary-border-color: var(--bs-dark); + --cc-btn-primary-hover-bg: var(--bs-primary); + --cc-btn-primary-hover-color: var(--bs-dark); + --cc-btn-primary-hover-border-color: var(--bs-dark); + --cc-btn-secondary-bg: var(--bs-dark); + --cc-btn-secondary-color: var(--bs-primary); + --cc-btn-secondary-border-color: var(--bs-dark); + --cc-btn-secondary-hover-bg: var(--bs-primary); + --cc-btn-secondary-hover-color: var(--bs-dark); + --cc-btn-secondary-hover-border-color: var(--bs-dark); + --cc-separator-border-color: var(--bs-border-color); + --cc-footer-bg: var(--bs-pmf-footer); + --cc-footer-border-color: var(--bs-border-color); + --cc-cookie-category-block-bg: var(--bs-dark); + --cc-cookie-category-block-border: var(--bs-border-color); + --cc-cookie-category-block-hover-bg: var(--bs-pmf-footer); + --cc-toggle-on-bg: var(--cc-btn-primary-bg); + --cc-toggle-off-bg: var(--cc-btn-secondary-bg); + --cc-toggle-on-knob-bg: var(--cc-btn-primary-color); + --cc-toggle-off-knob-bg: var(--cc-btn-primary-color); + --cc-toggle-enabled-icon-color: var(--cc-btn-primary-color); + --cc-toggle-disabled-icon-color: var(--cc-btn-primary-color); + --cc-toggle-readonly-bg: var(--bs-dark); + --cc-toggle-readonly-knob-bg: var(--bs-light); + --cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg); + --cc-modal-transition-duration: 0s; + + #cc-main { + &, .cm, .pm { + font-size: 1.3rem !important; + font-weight: 800 !important; + border: none !important; + background-color: var(--bs-primary) !important; + padding: 5px !important; + } + + .pm__body, .cm__body { + background-color: var(--bs-dark) !important; + color: var(--bs-light) !important; + } + + button { + outline-offset: -3px; + } + + .cm__desc, .pm__section-desc { + color: var(--bs-light) !important; + } + + a { + color: var(--bs-primary) !important; + + &:hover { + color: var(--bs-dark) !important; + background: var(--bs-primary) !important; + border-radius: 3px !important; + } + } + + .pm__close-btn:hover { + border-width: 3px; + } + + .pm__section--toggle { + border: 2px solid var(--cc-cookie-category-block-border); + } + } + + .pm__footer, .pm__header { + background-color: var(--bs-primary) !important; + color: var(--bs-dark) !important; + border: 2px solid var(--bs-primary) !important; + padding: 0.5rem 1rem !important; + text-decoration: none !important; + } + + .cm__btn, .pm__btn { + font-weight: 800 !important; + border-width: 2px !important; + text-transform: uppercase; + } + + .cm__btn, + .cm__btn:focus, + .pm__btn:focus, + .section__toggle:focus { + outline: 3px solid var(--bs-primary) !important; + outline-offset: 3px; + } + + @media screen and (max-width: 768px) { + #cc-main { + .cm, .pm { + padding: 2px !important; + } + } + + .cm__btns { + display: flex !important; + flex-direction: column !important; + } + + .cm__btn, .pm__btn { + width: 100% !important; + margin-right: 0 !important; + margin-bottom: 12px !important; + padding: 1rem !important; + } + + .cm__title, .pm__title { + word-break: break-word !important; + } + } +} diff --git a/phpmyfaq/assets/scss/layout/_faq.scss b/phpmyfaq/assets/scss/layout/_faq.scss index a85a9e95c1..e93b6b7b16 100644 --- a/phpmyfaq/assets/scss/layout/_faq.scss +++ b/phpmyfaq/assets/scss/layout/_faq.scss @@ -1,5 +1,122 @@ @import 'jodit/es2021/jodit.min.css'; +// Sidebar toggle functionality +.pmf-faq-container { + position: relative; +} + +#pmf-content-column { + transition: all 0.3s ease-in-out; + + &.pmf-content-expanded { + flex: 0 0 100%; + max-width: 100%; + } +} + +#pmf-sidebar { + transition: all 0.3s ease-in-out; + overflow: hidden; + + &.pmf-sidebar-collapsed { + flex: 0 0 0; + max-width: 0; + width: 0; + padding: 0 !important; + margin: 0 !important; + opacity: 0; + visibility: hidden; + + // Hide inner content to prevent layout issues + > * { + display: none; + } + } +} + +#pmf-sidebar-toggle { + position: fixed; + right: 0; + top: 50%; + transform: translateY(-50%); + z-index: 1030; + width: 28px; + height: 56px; + padding: 0; + border: 1px solid var(--bs-border-color); + border-right: none; + border-radius: 8px 0 0 8px; + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease-in-out; + + &:hover { + background-color: var(--bs-tertiary-bg); + width: 32px; + } + + &:focus { + outline: none; + box-shadow: + -2px 0 8px rgba(0, 0, 0, 0.1), + 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); + } + + i { + font-size: 1rem; + transition: transform 0.2s ease-in-out; + } + + &.collapsed { + right: 0; + + i { + transform: rotate(0deg); + } + } +} + +// Hide sidebar toggle on small screens (sidebar stacks below content anyway) +@media (max-width: 767.98px) { + #pmf-sidebar-toggle { + display: none; + } + + #pmf-sidebar.pmf-sidebar-collapsed { + flex: 0 0 100%; + max-width: 100%; + padding: 1rem !important; + opacity: 1; + visibility: visible; + } + + #pmf-content-column.pmf-content-expanded { + flex: 0 0 100%; + max-width: 100%; + } +} + +// Print styles - always show full content +@media print { + #pmf-sidebar-toggle { + display: none !important; + } + + #pmf-sidebar { + display: none !important; + } + + #pmf-content-column { + flex: 0 0 100% !important; + max-width: 100% !important; + } +} + .pmf-voting-star { background: none; border: none; diff --git a/phpmyfaq/assets/scss/layout/_theme-switcher.scss b/phpmyfaq/assets/scss/layout/_theme-switcher.scss index d44b03c1ae..196046ee33 100644 --- a/phpmyfaq/assets/scss/layout/_theme-switcher.scss +++ b/phpmyfaq/assets/scss/layout/_theme-switcher.scss @@ -259,7 +259,7 @@ color: var(--bs-light) !important; } - // Custom scrollbar for high contrast mode + // Custom scrollbar for high contrast mode scrollbar-color: var(--bs-primary) var(--bs-dark); scrollbar-width: auto; @@ -384,6 +384,11 @@ margin-top: -4px; } + .text-bg-pmf-footer { + background-color: #000000 !important; + border-top: 2px solid #ffffff !important; + } + // Links in high contrast mode - yellow for maximum visibility a { color: var(--bs-primary); @@ -499,6 +504,25 @@ } } + .text-body { + color: var(--bs-light) !important; + } + + a:hover { + color: var(--bs-dark) !important; + background-color: var(--bs-primary) !important; + } + + .js-unstick-button.sticky-toggle-btn { + background-color: var(--bs-dark) !important; + color: var(--bs-primary) !important; + + &:hover { + color: var(--bs-primary) !important; + outline: 2px solid var(--bs-light); + } + } + // Improve readability of badges .badge { border: 4px solid var(--bs-dark); diff --git a/phpmyfaq/assets/scss/style.scss b/phpmyfaq/assets/scss/style.scss index 93d5653f97..96bbb3f3ec 100644 --- a/phpmyfaq/assets/scss/style.scss +++ b/phpmyfaq/assets/scss/style.scss @@ -27,6 +27,7 @@ // Cookie Consent @import 'vanilla-cookieconsent/dist/cookieconsent.css'; +@import 'layout/cookie-custom'; // Highlight.js @import '../../../node_modules/highlight.js/styles/github-dark.css'; diff --git a/phpmyfaq/assets/src/api/chat.test.ts b/phpmyfaq/assets/src/api/chat.test.ts new file mode 100644 index 0000000000..7981f48975 --- /dev/null +++ b/phpmyfaq/assets/src/api/chat.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getConversations, getMessages, sendMessage, markAsRead, getUnreadCount, searchUsers } from './chat'; +import createFetchMock, { FetchMock } from 'vitest-fetch-mock'; +import { + ChatConversationsResponse, + ChatMessagesResponse, + ChatSendResponse, + ChatUnreadCountResponse, + ChatUsersResponse, +} from '../interfaces'; + +const fetchMocker: FetchMock = createFetchMock(vi); + +fetchMocker.enableMocks(); + +describe('Chat API', (): void => { + beforeEach((): void => { + fetchMocker.resetMocks(); + }); + + test('getConversations should return conversations when successful', async (): Promise => { + const mockResponse: ChatConversationsResponse = { + success: true, + conversations: [ + { + userId: 2, + displayName: 'John Doe', + lastMessage: 'Hello!', + lastMessageTime: '2026-01-23T10:00:00Z', + unreadCount: 1, + }, + ], + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getConversations(); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/chat/conversations', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getConversations should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(getConversations()).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('getMessages should return messages when successful', async (): Promise => { + const mockResponse: ChatMessagesResponse = { + success: true, + messages: [ + { + id: 1, + senderId: 1, + senderName: 'Me', + recipientId: 2, + message: 'Hello!', + isRead: true, + createdAt: '2026-01-23T10:00:00Z', + }, + { + id: 2, + senderId: 2, + senderName: 'John Doe', + recipientId: 1, + message: 'Hi there!', + isRead: false, + createdAt: '2026-01-23T10:01:00Z', + }, + ], + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getMessages(2, 50, 0); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/chat/messages/2?limit=50&offset=0', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getMessages should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 400 }); + + await expect(getMessages(0)).rejects.toThrow('HTTP 400'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('sendMessage should return response when successful', async (): Promise => { + const mockResponse: ChatSendResponse = { + success: true, + message: { + id: 3, + senderId: 1, + senderName: 'Me', + recipientId: 2, + message: 'New message!', + isRead: false, + createdAt: '2026-01-23T10:02:00Z', + }, + csrfToken: 'newToken123', + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await sendMessage(2, 'New message!', 'csrfToken'); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/chat/send', { + method: 'POST', + cache: 'no-cache', + body: JSON.stringify({ + recipientId: 2, + message: 'New message!', + csrfToken: 'csrfToken', + }), + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('sendMessage should handle validation error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 400 }); + + await expect(sendMessage(0, '', 'csrfToken')).rejects.toThrow('HTTP 400'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('sendMessage should handle auth error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(sendMessage(2, 'Hello', 'invalidToken')).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('markAsRead should return success when successful', async (): Promise => { + const mockResponse = { success: true }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await markAsRead(1, 'csrfToken'); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/chat/read/1', { + method: 'POST', + cache: 'no-cache', + body: JSON.stringify({ csrfToken: 'csrfToken' }), + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('markAsRead should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 400 }); + + await expect(markAsRead(0, 'csrfToken')).rejects.toThrow('HTTP 400'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('getUnreadCount should return count when successful', async (): Promise => { + const mockResponse: ChatUnreadCountResponse = { + success: true, + count: 5, + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getUnreadCount(); + + expect(data).toEqual(mockResponse); + expect(data.count).toBe(5); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/chat/unread-count', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getUnreadCount should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 500 }); + + await expect(getUnreadCount()).rejects.toThrow('HTTP 500'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('searchUsers should return users when successful', async (): Promise => { + const mockResponse: ChatUsersResponse = { + success: true, + users: [ + { userId: 2, displayName: 'John Doe', email: 'john@example.com' }, + { userId: 3, displayName: 'Jane Smith', email: 'jane@example.com' }, + ], + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await searchUsers('john'); + + expect(data).toEqual(mockResponse); + expect(data.users.length).toBe(2); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/chat/users?q=john', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('searchUsers should encode special characters in query', async (): Promise => { + const mockResponse: ChatUsersResponse = { + success: true, + users: [], + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + await searchUsers('john doe & jane'); + + expect(fetch).toHaveBeenCalledWith('api/chat/users?q=john%20doe%20%26%20jane', expect.any(Object)); + }); + + test('searchUsers should return empty array for short query', async (): Promise => { + const mockResponse: ChatUsersResponse = { + success: true, + users: [], + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await searchUsers('j'); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('searchUsers should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(searchUsers('john')).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/phpmyfaq/assets/src/api/chat.ts b/phpmyfaq/assets/src/api/chat.ts new file mode 100644 index 0000000000..409cd3a742 --- /dev/null +++ b/phpmyfaq/assets/src/api/chat.ts @@ -0,0 +1,142 @@ +/** + * Chat API functionality + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-23 + */ + +import { + ChatConversationsResponse, + ChatMessagesResponse, + ChatSendResponse, + ChatUnreadCountResponse, + ChatUsersResponse, +} from '../interfaces'; + +export const getConversations = async (): Promise => { + const response: Response = await fetch('api/chat/conversations', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const getMessages = async (userId: number, limit = 50, offset = 0): Promise => { + const response: Response = await fetch(`api/chat/messages/${userId}?limit=${limit}&offset=${offset}`, { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const sendMessage = async ( + recipientId: number, + message: string, + csrfToken: string +): Promise => { + const response: Response = await fetch('api/chat/send', { + method: 'POST', + cache: 'no-cache', + body: JSON.stringify({ + recipientId, + message, + csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const markAsRead = async (messageId: number, csrfToken: string): Promise<{ success: boolean }> => { + const response: Response = await fetch(`api/chat/read/${messageId}`, { + method: 'POST', + cache: 'no-cache', + body: JSON.stringify({ + csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const getUnreadCount = async (): Promise => { + const response: Response = await fetch('api/chat/unread-count', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const searchUsers = async (query: string): Promise => { + const response: Response = await fetch(`api/chat/users?q=${encodeURIComponent(query)}`, { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; diff --git a/phpmyfaq/assets/src/api/push.test.ts b/phpmyfaq/assets/src/api/push.test.ts new file mode 100644 index 0000000000..c071b5fdb2 --- /dev/null +++ b/phpmyfaq/assets/src/api/push.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getVapidPublicKey, unsubscribePush, getPushStatus } from './push'; +import createFetchMock, { FetchMock } from 'vitest-fetch-mock'; +import type { VapidPublicKeyResponse, PushSubscribeResponse, PushStatusResponse } from './push'; + +const fetchMocker: FetchMock = createFetchMock(vi); + +fetchMocker.enableMocks(); + +describe('Push API', (): void => { + beforeEach((): void => { + fetchMocker.resetMocks(); + }); + + test('getVapidPublicKey should return key and enabled status when successful', async (): Promise => { + const mockResponse: VapidPublicKeyResponse = { + enabled: true, + vapidPublicKey: 'BNcR...testPublicKey', + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getVapidPublicKey(); + + expect(data).toEqual(mockResponse); + expect(data.enabled).toBe(true); + expect(data.vapidPublicKey).toBe('BNcR...testPublicKey'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/push/vapid-public-key', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getVapidPublicKey should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 500 }); + + await expect(getVapidPublicKey()).rejects.toThrow('HTTP 500'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('unsubscribePush should send endpoint and return success', async (): Promise => { + const mockResponse: PushSubscribeResponse = { success: true }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await unsubscribePush('https://fcm.googleapis.com/fcm/send/abc123'); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/push/unsubscribe', { + method: 'POST', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: 'https://fcm.googleapis.com/fcm/send/abc123' }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('unsubscribePush should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(unsubscribePush('https://example.com/push')).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('getPushStatus should return subscription status', async (): Promise => { + const mockResponse: PushStatusResponse = { subscribed: true }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getPushStatus(); + + expect(data).toEqual(mockResponse); + expect(data.subscribed).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/push/status', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getPushStatus should return false when not subscribed', async (): Promise => { + const mockResponse: PushStatusResponse = { subscribed: false }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getPushStatus(); + + expect(data.subscribed).toBe(false); + }); + + test('getPushStatus should handle auth error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(getPushStatus()).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/phpmyfaq/assets/src/api/push.ts b/phpmyfaq/assets/src/api/push.ts new file mode 100644 index 0000000000..4058165ace --- /dev/null +++ b/phpmyfaq/assets/src/api/push.ts @@ -0,0 +1,117 @@ +/** + * Push notification API module + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-02 + */ + +export interface VapidPublicKeyResponse { + enabled: boolean; + vapidPublicKey: string; +} + +export interface PushSubscribeResponse { + success?: boolean; + error?: string; +} + +export interface PushStatusResponse { + subscribed: boolean; +} + +export const getVapidPublicKey = async (): Promise => { + const response: Response = await fetch('api/push/vapid-public-key', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const subscribePush = async (subscription: PushSubscription): Promise => { + const key = subscription.getKey('p256dh'); + const auth = subscription.getKey('auth'); + + if (!key || !auth) { + throw new Error('Missing subscription keys'); + } + + const publicKey = btoa(String.fromCharCode(...new Uint8Array(key))); + const authToken = btoa(String.fromCharCode(...new Uint8Array(auth))); + + const response: Response = await fetch('api/push/subscribe', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + publicKey: publicKey, + authToken: authToken, + contentEncoding: (PushManager.supportedContentEncodings || ['aesgcm'])[0], + }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const unsubscribePush = async (endpoint: string): Promise => { + const response: Response = await fetch('api/push/unsubscribe', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const getPushStatus = async (): Promise => { + const response: Response = await fetch('api/push/status', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; diff --git a/phpmyfaq/assets/src/chat/index.ts b/phpmyfaq/assets/src/chat/index.ts new file mode 100644 index 0000000000..a0d3994fa7 --- /dev/null +++ b/phpmyfaq/assets/src/chat/index.ts @@ -0,0 +1,381 @@ +/** + * Chat UI functionality + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-23 + */ + +import { getMessages, sendMessage, searchUsers, getConversations } from '../api/chat'; +import { ChatConfig, ChatMessage, ChatUser } from '../interfaces'; + +declare global { + interface Window { + pmfChatConfig?: ChatConfig; + } +} + +let eventSource: EventSource | null = null; +let lastMessageId = 0; +let currentPartnerId: number | null = null; +let csrfToken: string; + +const escapeHtml = (text: string): string => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +}; + +const formatTime = (isoString: string): string => { + const date = new Date(isoString); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +}; + +const renderMessage = (message: ChatMessage, currentUserId: number): string => { + const isOwn = message.senderId === currentUserId; + const alignClass = isOwn ? 'justify-content-end' : 'justify-content-start'; + const bgClass = isOwn ? 'bg-primary text-white' : 'bg-body-secondary'; + + return ` +
    +
    +
    ${escapeHtml(message.message)}
    + ${formatTime(message.createdAt)} +
    +
    + `; +}; + +const scrollToBottom = (): void => { + const messagesContainer = document.getElementById('pmf-chat-messages'); + if (messagesContainer) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } +}; + +const connectSSE = (userId: number): void => { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource(`api/chat/stream?lastId=${lastMessageId}`); + + eventSource.onmessage = (event) => { + const messages: ChatMessage[] = JSON.parse(event.data); + const messagesContainer = document.getElementById('pmf-chat-messages'); + const config = window.pmfChatConfig; + + if (!messagesContainer || !config) return; + + messages.forEach((message) => { + // Only append if this message is part of the current conversation + if ( + (message.senderId === currentPartnerId && message.recipientId === config.currentUserId) || + (message.senderId === config.currentUserId && message.recipientId === currentPartnerId) + ) { + messagesContainer.insertAdjacentHTML('beforeend', renderMessage(message, config.currentUserId)); + } + + // Update lastMessageId + if (message.id > lastMessageId) { + lastMessageId = message.id; + } + }); + + scrollToBottom(); + }; + + eventSource.addEventListener('reconnect', (event: Event) => { + const messageEvent = event as MessageEvent; + const data = JSON.parse(messageEvent.data); + lastMessageId = data.lastId; + // Reconnect after a short delay + setTimeout(() => connectSSE(userId), 1000); + }); + + eventSource.onerror = () => { + eventSource?.close(); + // Attempt to reconnect after 5 seconds + setTimeout(() => connectSSE(userId), 5000); + }; +}; + +const loadConversation = async (partnerId: number, partnerName: string): Promise => { + const config = window.pmfChatConfig; + if (!config) return; + + currentPartnerId = partnerId; + + // Update UI elements + const header = document.getElementById('pmf-chat-header'); + const placeholder = document.getElementById('pmf-chat-placeholder'); + const inputArea = document.getElementById('pmf-chat-input-area'); + const partnerNameEl = document.getElementById('pmf-chat-partner-name'); + const partnerAvatar = document.getElementById('pmf-chat-partner-avatar'); + const recipientInput = document.getElementById('pmf-chat-recipient-id') as HTMLInputElement; + const messagesContainer = document.getElementById('pmf-chat-messages'); + + if (header) header.classList.remove('d-none'); + if (placeholder) placeholder.classList.add('d-none'); + if (inputArea) inputArea.classList.remove('d-none'); + if (partnerNameEl) partnerNameEl.textContent = partnerName; + if (partnerAvatar) partnerAvatar.textContent = partnerName.charAt(0).toUpperCase(); + if (recipientInput) recipientInput.value = String(partnerId); + + // Clear messages container + if (messagesContainer) { + messagesContainer.innerHTML = + '
    '; + } + + try { + const response = await getMessages(partnerId); + + if (response.success && messagesContainer) { + messagesContainer.innerHTML = ''; + + if (response.messages.length === 0) { + messagesContainer.innerHTML = + '
    No messages yet. Start the conversation!
    '; + } else { + response.messages.forEach((message) => { + messagesContainer.insertAdjacentHTML('beforeend', renderMessage(message, config.currentUserId)); + if (message.id > lastMessageId) { + lastMessageId = message.id; + } + }); + scrollToBottom(); + } + } + } catch (error) { + console.error('Failed to load conversation:', error); + if (messagesContainer) { + messagesContainer.innerHTML = '
    Failed to load messages
    '; + } + } + + // Mark conversation as active in the sidebar + document.querySelectorAll('.pmf-chat-conversation').forEach((el) => { + el.classList.remove('active'); + if (el.getAttribute('data-user-id') === String(partnerId)) { + el.classList.add('active'); + // Remove unread badge + const badge = el.querySelector('.badge'); + if (badge) badge.remove(); + } + }); +}; + +const handleSendMessage = async (event: Event): Promise => { + event.preventDefault(); + + const config = window.pmfChatConfig; + if (!config || !currentPartnerId) return; + + const messageInput = document.getElementById('pmf-chat-message-input') as HTMLInputElement; + const message = messageInput?.value.trim(); + + if (!message) return; + + try { + const response = await sendMessage(currentPartnerId, message, csrfToken); + + if (response.success && response.message) { + const messagesContainer = document.getElementById('pmf-chat-messages'); + const placeholder = messagesContainer?.querySelector('.text-muted.py-4'); + if (placeholder) placeholder.remove(); + + if (messagesContainer) { + messagesContainer.insertAdjacentHTML('beforeend', renderMessage(response.message, config.currentUserId)); + scrollToBottom(); + } + + // Update CSRF token for next request + if (response.csrfToken) { + csrfToken = response.csrfToken; + const csrfInput = document.getElementById('pmf-chat-csrf-token') as HTMLInputElement; + if (csrfInput) csrfInput.value = csrfToken; + } + + // Clear input + messageInput.value = ''; + } + } catch (error) { + console.error('Failed to send message:', error); + } +}; + +const handleUserSearch = async (query: string): Promise => { + const resultsContainer = document.getElementById('pmf-chat-user-search-results'); + if (!resultsContainer) return; + + if (query.length < 2) { + resultsContainer.innerHTML = ''; + return; + } + + try { + const response = await searchUsers(query); + + if (response.success) { + if (response.users.length === 0) { + resultsContainer.innerHTML = + '
    No users found
    '; + } else { + const userList = response.users + .map( + (user: ChatUser) => ` +
    +
    +
    + ${user.displayName.charAt(0).toUpperCase()} +
    +
    + ${escapeHtml(user.displayName)} +
    +
    +
    + ` + ) + .join(''); + + resultsContainer.innerHTML = `
    ${userList}
    `; + + // Add click handlers + resultsContainer.querySelectorAll('.pmf-chat-start-conversation').forEach((el) => { + el.addEventListener('click', (e) => { + e.preventDefault(); + const userId = parseInt(el.getAttribute('data-user-id') || '0', 10); + const displayName = el.getAttribute('data-display-name') || 'User'; + if (userId) { + loadConversation(userId, displayName); + resultsContainer.innerHTML = ''; + const searchInput = document.getElementById('pmf-chat-user-search') as HTMLInputElement; + if (searchInput) searchInput.value = ''; + } + }); + }); + } + } + } catch (error) { + console.error('Failed to search users:', error); + } +}; + +const updateConversationList = async (): Promise => { + try { + const response = await getConversations(); + const conversationList = document.getElementById('pmf-chat-conversation-list'); + + if (!response.success || !conversationList) return; + + if (response.conversations.length === 0) { + conversationList.innerHTML = ` +
    + + No messages yet +
    + `; + return; + } + + conversationList.innerHTML = response.conversations + .map( + (conv) => ` + +
    +
    + ${conv.displayName.charAt(0).toUpperCase()} +
    +
    +
    +
    + ${escapeHtml(conv.displayName)} + ${conv.unreadCount > 0 ? `${conv.unreadCount}` : ''} +
    + ${escapeHtml(conv.lastMessage.slice(0, 30))}${conv.lastMessage.length > 30 ? '...' : ''} +
    +
    + ` + ) + .join(''); + + // Re-attach click handlers + attachConversationClickHandlers(); + } catch (error) { + console.error('Failed to update conversation list:', error); + } +}; + +const attachConversationClickHandlers = (): void => { + document.querySelectorAll('.pmf-chat-conversation').forEach((el) => { + el.addEventListener('click', (e) => { + e.preventDefault(); + const userId = parseInt(el.getAttribute('data-user-id') || '0', 10); + const displayName = el.querySelector('strong')?.textContent || 'User'; + if (userId) { + loadConversation(userId, displayName); + } + }); + }); +}; + +export const handleChat = (): void => { + const config = window.pmfChatConfig; + const chatForm = document.getElementById('pmf-chat-form'); + const userSearchInput = document.getElementById('pmf-chat-user-search') as HTMLInputElement; + + if (!config || !chatForm) return; + + csrfToken = config.csrfTokenSendMessage; + + // Initialize SSE connection + connectSSE(config.currentUserId); + + // Handle form submission + chatForm.addEventListener('submit', handleSendMessage); + + // Handle conversation clicks + attachConversationClickHandlers(); + + // Handle user search + if (userSearchInput) { + let searchTimeout: ReturnType; + userSearchInput.addEventListener('input', async () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + handleUserSearch(userSearchInput.value); + }, 300); + }); + + // Close search results when clicking outside + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const resultsContainer = document.getElementById('pmf-chat-user-search-results'); + if (resultsContainer && !resultsContainer.contains(target) && target !== userSearchInput) { + resultsContainer.innerHTML = ''; + } + }); + } + + // Periodically update a conversation list (every 30 seconds) + setInterval(updateConversationList, 30000); + + // Cleanup on page unloaded + window.addEventListener('beforeunload', () => { + if (eventSource) { + eventSource.close(); + } + }); +}; diff --git a/phpmyfaq/assets/src/comment/editor.ts b/phpmyfaq/assets/src/comment/editor.ts new file mode 100644 index 0000000000..139c5d6472 --- /dev/null +++ b/phpmyfaq/assets/src/comment/editor.ts @@ -0,0 +1,165 @@ +/** + * Minimal Jodit Editor for Comment Forms + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-19 + */ + +import { Jodit } from 'jodit'; + +import 'jodit/esm/plugins/clean-html/clean-html.js'; +import 'jodit/esm/plugins/clipboard/clipboard.js'; +import 'jodit/esm/plugins/delete/delete.js'; +import 'jodit/esm/plugins/indent/indent.js'; +import 'jodit/esm/plugins/paste-from-word/paste-from-word.js'; +import 'jodit/esm/plugins/select/select.js'; + +/** + * Renders a minimal Jodit editor for comment forms + * Features: + * - Basic text formatting (bold, italic, underline, strikethrough) + * - Lists (ul, ol) + * - Links + * - No file/image uploads (security) + * - No advanced features (tables, media, alignment) + */ +export const renderCommentEditor = (selector: string): Jodit | null => { + const commentField = document.querySelector(selector); + + if (!commentField) { + return null; + } + + // Detect browser color scheme preference (dark/light) + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + + const joditEditor = Jodit.make(commentField, { + zIndex: 0, + readonly: false, + beautifyHTML: true, + sourceEditor: 'area', + toolbarButtonSize: 'middle', + theme: prefersDark.matches ? 'dark' : 'default', + saveModeInStorage: false, + spellcheck: true, + editorClassName: false, + triggerChangeEvent: true, + width: 'auto', + height: 'auto', + minHeight: 200, + maxHeight: 400, + direction: '', + language: 'auto', + debugLanguage: false, + tabIndex: -1, + toolbar: true, + enter: 'p', + defaultMode: 1, // MODE_WYSIWYG + useSplitMode: false, + askBeforePasteFromWord: true, + processPasteFromWord: true, + defaultActionOnPasteFromWord: 'insert_clear_html', + showCharsCounter: false, + showWordsCounter: false, + showXPathInStatusbar: false, + toolbarAdaptive: false, + + // Minimal button set for basic formatting + buttons: [ + 'bold', + 'italic', + 'underline', + 'strikethrough', + '|', + 'ul', + 'ol', + '|', + 'link', + 'unlink', + '|', + 'undo', + 'redo', + ], + + // Disable all upload-related and advanced plugins + disablePlugins: [ + 'add-new-line', + 'image', + 'video', + 'file', + 'media', + 'table', + 'iframe', + 'drag-and-drop', + 'drag-and-drop-element', + 'fullsize', + 'preview', + 'source', + ], + + // Disable uploaders completely + uploader: { + url: '', + insertImageAsBase64URI: false, + }, + filebrowser: { + ajax: { + url: '', + }, + }, + + events: {}, + textIcons: false, + }); + + // Theme switching support + const setJoditTheme = (theme: 'dark' | 'default'): void => { + joditEditor.options.theme = theme; + + const container: HTMLDivElement = joditEditor.container; + container.classList.remove('jodit_theme_default', 'jodit_theme_dark'); + container.classList.add(theme === 'dark' ? 'jodit_theme_dark' : 'jodit_theme_default'); + }; + + const applyTheme = (): void => { + setJoditTheme(prefersDark.matches ? 'dark' : 'default'); + }; + + // Listen for system color scheme changes + if (typeof prefersDark.addEventListener === 'function') { + prefersDark.addEventListener('change', applyTheme); + } else if ( + typeof (prefersDark as MediaQueryList & { addListener?: (listener: () => void) => void }).addListener === 'function' + ) { + (prefersDark as MediaQueryList & { addListener: (listener: () => void) => void }).addListener(applyTheme); + } + + // Listen for Bootstrap theme attribute changes + const applyThemeFromAttribute = (): void => { + const themeAttr: string | null = document.documentElement.getAttribute('data-bs-theme'); + setJoditTheme(themeAttr === 'dark' ? 'dark' : 'default'); + }; + + const themeObserver = new MutationObserver((mutations): void => { + for (const m of mutations) { + if (m.type === 'attributes' && m.attributeName === 'data-bs-theme') { + applyThemeFromAttribute(); + } + } + }); + + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bs-theme'] }); + + // Apply initial theme from Bootstrap attribute if set + applyThemeFromAttribute(); + + return joditEditor; +}; diff --git a/phpmyfaq/assets/src/configuration/update.ts b/phpmyfaq/assets/src/configuration/update.ts index 6eb190a961..0d4da4a422 100644 --- a/phpmyfaq/assets/src/configuration/update.ts +++ b/phpmyfaq/assets/src/configuration/update.ts @@ -30,7 +30,7 @@ export const handleUpdateNextStepButton = (): void => { }; export const handleUpdateInformation = async (): Promise => { - if (window.location.href.endsWith('/update/') || window.location.href.endsWith('/update/index.php')) { + if (window.location.href.endsWith('/update') || window.location.href.endsWith('/update/')) { const installedVersion = document.getElementById('phpmyfaq-update-installed-version') as HTMLInputElement | null; if (!installedVersion) return; @@ -87,7 +87,7 @@ export const handleUpdateInformation = async (): Promise => { }; export const handleConfigBackup = async (): Promise => { - if (window.location.href.endsWith('/update/?step=2') || window.location.href.endsWith('/update/index.php?step=2')) { + if (window.location.href.endsWith('/update?step=2') || window.location.href.endsWith('/update/?step=2')) { const installedVersion = document.getElementById('phpmyfaq-update-installed-version') as HTMLInputElement | null; if (!installedVersion) return; @@ -115,7 +115,7 @@ export const handleConfigBackup = async (): Promise => { }; export const handleDatabaseUpdate = async (): Promise => { - if (window.location.href.endsWith('/update/?step=3') || window.location.href.endsWith('/update/index.php?step=3')) { + if (window.location.href.endsWith('/update?step=3') || window.location.href.endsWith('/update/?step=3')) { const installedVersion = document.getElementById('phpmyfaq-update-installed-version') as HTMLInputElement | null; if (!installedVersion) return; diff --git a/phpmyfaq/assets/src/faq/comments.ts b/phpmyfaq/assets/src/faq/comments.ts index 5bcac2d793..2fa01d082f 100644 --- a/phpmyfaq/assets/src/faq/comments.ts +++ b/phpmyfaq/assets/src/faq/comments.ts @@ -17,6 +17,11 @@ import { Modal } from 'bootstrap'; import { pushErrorNotification, pushNotification } from '../utils'; import { createComment } from '../api'; import { ApiResponse, CommentData } from '../interfaces'; +import { renderCommentEditor } from '../comment/editor'; +import type { Jodit } from 'jodit'; + +// Store the Jodit editor instance +let joditInstance: Jodit | null = null; export const handleSaveComment = (): void => { const saveButton = document.getElementById('pmf-button-save-comment') as HTMLButtonElement | null; @@ -31,6 +36,14 @@ export const handleSaveComment = (): void => { form.classList.add('was-validated'); } else { try { + // Sync Jodit content to textarea before form submission + if (joditInstance) { + const textarea = document.getElementById('comment_text') as HTMLTextAreaElement; + if (textarea) { + textarea.value = joditInstance.value; + } + } + const comments = new FormData(form); const response = (await createComment(comments)) as ApiResponse; @@ -51,6 +64,9 @@ export const handleSaveComment = (): void => { const bootstrapModal = Modal.getInstance(modalElement) || new Modal(modalElement); bootstrapModal.hide(); + // Destroy editor instance on successful submission + destroyEditor(); + form.reset(); } catch (error: unknown) { console.error('Error: ', error); @@ -58,6 +74,52 @@ export const handleSaveComment = (): void => { } }); } + + // Initialize editor on modal show + initializeCommentEditor(); +}; + +/** + * Initializes the comment editor when the modal is shown + */ +const initializeCommentEditor = (): void => { + const modal = document.getElementById('pmf-modal-add-comment') as HTMLElement | null; + + if (!modal) { + return; + } + + // Check if editor should be enabled + const enableEditor = modal.getAttribute('data-enable-editor') === 'true'; + const isLoggedIn = modal.getAttribute('data-is-logged-in') === 'true'; + + if (!enableEditor || !isLoggedIn) { + return; + } + + // Listen for Bootstrap modal show event + modal.addEventListener('show.bs.modal', () => { + // Destroy existing instance if any + destroyEditor(); + + // Initialize new editor instance + joditInstance = renderCommentEditor('#comment_text'); + }); + + // Listen for Bootstrap modal hide event + modal.addEventListener('hide.bs.modal', () => { + destroyEditor(); + }); +}; + +/** + * Destroys the Jodit editor instance + */ +const destroyEditor = (): void => { + if (joditInstance) { + joditInstance.destruct(); + joditInstance = null; + } }; const addCommentToDOM = (commentData: CommentData): void => { @@ -80,7 +142,6 @@ const addCommentToDOM = (commentData: CommentData): void => { const escapedUsername = escapeHtml(commentData.username); const escapedEmail = escapeHtml(commentData.email); - // Create the comment HTML matching the structure from CommentHelper::getComments() const commentHtml = `
    diff --git a/phpmyfaq/assets/src/faq/index.ts b/phpmyfaq/assets/src/faq/index.ts index 092b9ba900..6ff215a7c5 100644 --- a/phpmyfaq/assets/src/faq/index.ts +++ b/phpmyfaq/assets/src/faq/index.ts @@ -2,4 +2,5 @@ export * from './comments'; export * from './editor'; export * from './faq'; export * from './highlight'; +export * from './sidebar'; export * from './voting'; diff --git a/phpmyfaq/assets/src/faq/sidebar.test.ts b/phpmyfaq/assets/src/faq/sidebar.test.ts new file mode 100644 index 0000000000..f8e454fb42 --- /dev/null +++ b/phpmyfaq/assets/src/faq/sidebar.test.ts @@ -0,0 +1,214 @@ +/** + * Unit tests for the FAQ Sidebar toggle functionality + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-24 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { handleSidebarToggle } from './sidebar'; + +const STORAGE_KEY = 'pmf-sidebar-collapsed'; + +const createSidebarHTML = (): string => ` + +
    +
    +
    Content
    +
    +
    +
    Sidebar content
    +
    +
    +`; + +describe('handleSidebarToggle', () => { + beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('should return early when toggle button is missing', () => { + document.body.innerHTML = ` +
    +
    + `; + + handleSidebarToggle(); + + const sidebar = document.getElementById('pmf-sidebar'); + expect(sidebar?.classList.contains('pmf-sidebar-collapsed')).toBe(false); + }); + + it('should return early when sidebar is missing', () => { + document.body.innerHTML = ` + +
    + `; + + handleSidebarToggle(); + + const button = document.getElementById('pmf-sidebar-toggle'); + expect(button?.classList.contains('collapsed')).toBe(false); + }); + + it('should return early when content column is missing', () => { + document.body.innerHTML = ` + +
    + `; + + handleSidebarToggle(); + + const sidebar = document.getElementById('pmf-sidebar'); + expect(sidebar?.classList.contains('pmf-sidebar-collapsed')).toBe(false); + }); + + it('should not collapse sidebar when localStorage is empty', () => { + document.body.innerHTML = createSidebarHTML(); + + handleSidebarToggle(); + + const sidebar = document.getElementById('pmf-sidebar'); + const contentColumn = document.getElementById('pmf-content-column'); + const toggleButton = document.getElementById('pmf-sidebar-toggle'); + + expect(sidebar?.classList.contains('pmf-sidebar-collapsed')).toBe(false); + expect(contentColumn?.classList.contains('pmf-content-expanded')).toBe(false); + expect(toggleButton?.classList.contains('collapsed')).toBe(false); + }); + + it('should collapse sidebar when localStorage has true', () => { + document.body.innerHTML = createSidebarHTML(); + localStorage.setItem(STORAGE_KEY, 'true'); + + handleSidebarToggle(); + + const sidebar = document.getElementById('pmf-sidebar'); + const contentColumn = document.getElementById('pmf-content-column'); + const toggleButton = document.getElementById('pmf-sidebar-toggle'); + + expect(sidebar?.classList.contains('pmf-sidebar-collapsed')).toBe(true); + expect(contentColumn?.classList.contains('pmf-content-expanded')).toBe(true); + expect(toggleButton?.classList.contains('collapsed')).toBe(true); + }); + + it('should update icon to chevron-left when collapsed from localStorage', () => { + document.body.innerHTML = createSidebarHTML(); + localStorage.setItem(STORAGE_KEY, 'true'); + + handleSidebarToggle(); + + const icon = document.querySelector('#pmf-sidebar-toggle i'); + expect(icon?.classList.contains('bi-chevron-left')).toBe(true); + expect(icon?.classList.contains('bi-chevron-right')).toBe(false); + }); + + it('should toggle sidebar collapsed state on button click', () => { + document.body.innerHTML = createSidebarHTML(); + + handleSidebarToggle(); + + const toggleButton = document.getElementById('pmf-sidebar-toggle') as HTMLButtonElement; + const sidebar = document.getElementById('pmf-sidebar'); + const contentColumn = document.getElementById('pmf-content-column'); + + // Click to collapse + toggleButton.click(); + + expect(sidebar?.classList.contains('pmf-sidebar-collapsed')).toBe(true); + expect(contentColumn?.classList.contains('pmf-content-expanded')).toBe(true); + expect(toggleButton.classList.contains('collapsed')).toBe(true); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + + // Click to expand + toggleButton.click(); + + expect(sidebar?.classList.contains('pmf-sidebar-collapsed')).toBe(false); + expect(contentColumn?.classList.contains('pmf-content-expanded')).toBe(false); + expect(toggleButton.classList.contains('collapsed')).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBe('false'); + }); + + it('should update icon on toggle', () => { + document.body.innerHTML = createSidebarHTML(); + + handleSidebarToggle(); + + const toggleButton = document.getElementById('pmf-sidebar-toggle') as HTMLButtonElement; + const icon = document.querySelector('#pmf-sidebar-toggle i'); + + // Initial state - chevron-right + expect(icon?.classList.contains('bi-chevron-right')).toBe(true); + + // Click to collapse - should show chevron-left + toggleButton.click(); + expect(icon?.classList.contains('bi-chevron-left')).toBe(true); + expect(icon?.classList.contains('bi-chevron-right')).toBe(false); + + // Click to expand - should show chevron-right + toggleButton.click(); + expect(icon?.classList.contains('bi-chevron-right')).toBe(true); + expect(icon?.classList.contains('bi-chevron-left')).toBe(false); + }); + + it('should handle button without icon gracefully', () => { + document.body.innerHTML = ` + +
    +
    +
    +
    + `; + + handleSidebarToggle(); + + const toggleButton = document.getElementById('pmf-sidebar-toggle') as HTMLButtonElement; + + // Should not throw when clicking without icon + expect(() => toggleButton.click()).not.toThrow(); + }); + + it('should prevent default event behavior on click', () => { + document.body.innerHTML = createSidebarHTML(); + + handleSidebarToggle(); + + const toggleButton = document.getElementById('pmf-sidebar-toggle') as HTMLButtonElement; + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault'); + + toggleButton.dispatchEvent(clickEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should persist collapsed state across multiple toggles', () => { + document.body.innerHTML = createSidebarHTML(); + + handleSidebarToggle(); + + const toggleButton = document.getElementById('pmf-sidebar-toggle') as HTMLButtonElement; + + // Toggle multiple times + toggleButton.click(); // collapse + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + + toggleButton.click(); // expand + expect(localStorage.getItem(STORAGE_KEY)).toBe('false'); + + toggleButton.click(); // collapse + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); +}); diff --git a/phpmyfaq/assets/src/faq/sidebar.ts b/phpmyfaq/assets/src/faq/sidebar.ts new file mode 100644 index 0000000000..029f605e52 --- /dev/null +++ b/phpmyfaq/assets/src/faq/sidebar.ts @@ -0,0 +1,58 @@ +/** + * FAQ Sidebar toggle functionality + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2024-2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-24 + */ + +const STORAGE_KEY = 'pmf-sidebar-collapsed'; + +export const handleSidebarToggle = (): void => { + const toggleButton = document.getElementById('pmf-sidebar-toggle'); + const sidebar = document.getElementById('pmf-sidebar'); + const contentColumn = document.getElementById('pmf-content-column'); + + if (!toggleButton || !sidebar || !contentColumn) { + return; + } + + const isCollapsed = localStorage.getItem(STORAGE_KEY) === 'true'; + if (isCollapsed) { + sidebar.classList.add('pmf-sidebar-collapsed'); + contentColumn.classList.add('pmf-content-expanded'); + toggleButton.classList.add('collapsed'); + updateToggleIcon(toggleButton, true); + } + + toggleButton.addEventListener('click', (event: Event) => { + event.preventDefault(); + + const nowCollapsed = sidebar.classList.toggle('pmf-sidebar-collapsed'); + contentColumn.classList.toggle('pmf-content-expanded'); + toggleButton.classList.toggle('collapsed'); + + localStorage.setItem(STORAGE_KEY, String(nowCollapsed)); + updateToggleIcon(toggleButton, nowCollapsed); + }); +}; + +const updateToggleIcon = (button: HTMLElement, isCollapsed: boolean): void => { + const icon = button.querySelector('i'); + if (icon) { + if (isCollapsed) { + icon.classList.remove('bi-chevron-right'); + icon.classList.add('bi-chevron-left'); + } else { + icon.classList.remove('bi-chevron-left'); + icon.classList.add('bi-chevron-right'); + } + } +}; diff --git a/phpmyfaq/assets/src/frontend.ts b/phpmyfaq/assets/src/frontend.ts index 59d255012a..fe3f854d69 100644 --- a/phpmyfaq/assets/src/frontend.ts +++ b/phpmyfaq/assets/src/frontend.ts @@ -21,6 +21,7 @@ import { handleSaveComment, handleShareLinkButton, handleShowFaq, + handleSidebarToggle, handleUserVoting, renderFaqEditor, } from './faq'; @@ -36,6 +37,8 @@ import { import { calculateReadingTime, handlePasswordStrength, handlePasswordToggle, handleReloadCaptcha } from './utils'; import './utils/tooltip'; import { handleWebAuthn } from './webauthn/webauthn'; +import { handleChat } from './chat'; +import { handlePushNotifications } from './push'; document.addEventListener('DOMContentLoaded', (): void => { // Reload Captchas @@ -73,6 +76,7 @@ document.addEventListener('DOMContentLoaded', (): void => { // Handle show FAQ handleShowFaq(); handleShareLinkButton(); + handleSidebarToggle(); // Handle Adds a Question handleQuestion(); @@ -97,7 +101,7 @@ document.addEventListener('DOMContentLoaded', (): void => { handleRegister(); handleWebAuthn(); - // Masonry on the startpage + // Masonry on the start page const masonryElement: HTMLElement | null = document.querySelector('.masonry-grid'); if (masonryElement) { new Masonry(masonryElement, { columnWidth: 0 }); @@ -106,4 +110,10 @@ document.addEventListener('DOMContentLoaded', (): void => { // AutoComplete handleAutoComplete(); handleCategorySelection(); + + // Handle Chat + handleChat(); + + // Handle Push Notifications + handlePushNotifications(); }); diff --git a/phpmyfaq/assets/src/interfaces/Chat.ts b/phpmyfaq/assets/src/interfaces/Chat.ts new file mode 100644 index 0000000000..b685e520e2 --- /dev/null +++ b/phpmyfaq/assets/src/interfaces/Chat.ts @@ -0,0 +1,75 @@ +/** + * Chat interfaces + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-23 + */ + +export interface ChatMessage { + id: number; + senderId: number; + senderName: string; + recipientId: number; + message: string; + isRead: boolean; + createdAt: string; +} + +export interface ChatConversation { + userId: number; + displayName: string; + lastMessage: string; + lastMessageTime: string; + unreadCount: number; +} + +export interface ChatUser { + userId: number; + displayName: string; + email: string; +} + +export interface ChatConversationsResponse { + success: boolean; + conversations: ChatConversation[]; + error?: string; +} + +export interface ChatMessagesResponse { + success: boolean; + messages: ChatMessage[]; + error?: string; +} + +export interface ChatSendResponse { + success: boolean; + message?: ChatMessage; + csrfToken?: string; + error?: string; +} + +export interface ChatUnreadCountResponse { + success: boolean; + count: number; + error?: string; +} + +export interface ChatUsersResponse { + success: boolean; + users: ChatUser[]; + error?: string; +} + +export interface ChatConfig { + currentUserId: number; + csrfTokenSendMessage: string; + csrfTokenMarkRead: string; +} diff --git a/phpmyfaq/assets/src/interfaces/index.ts b/phpmyfaq/assets/src/interfaces/index.ts index 7265665b9f..3a806039b7 100644 --- a/phpmyfaq/assets/src/interfaces/index.ts +++ b/phpmyfaq/assets/src/interfaces/index.ts @@ -2,4 +2,5 @@ export * from './Autocomplete'; export * from './apiResponse'; export * from './Bookmark'; export * from './authenticatorResponse'; +export * from './Chat'; export * from './suggestionItem'; diff --git a/phpmyfaq/assets/src/notifications-export.ts b/phpmyfaq/assets/src/notifications-export.ts new file mode 100644 index 0000000000..08a18747c3 --- /dev/null +++ b/phpmyfaq/assets/src/notifications-export.ts @@ -0,0 +1,16 @@ +/** + * Notification exports for template usage + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-01-02 + */ + +export { pushNotification, pushErrorNotification } from './utils/notifications'; diff --git a/phpmyfaq/assets/src/push/index.ts b/phpmyfaq/assets/src/push/index.ts new file mode 100644 index 0000000000..f0932a06ff --- /dev/null +++ b/phpmyfaq/assets/src/push/index.ts @@ -0,0 +1,279 @@ +/** + * Push notification handler + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-02 + */ + +import { getVapidPublicKey, subscribePush, unsubscribePush } from '../api/push'; +import { addElement } from '../utils'; + +const PUSH_DISMISSED_KEY = 'pmf-push-banner-dismissed'; + +const urlBase64ToUint8Array = (base64String: string): Uint8Array => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + + // Use an explicit ArrayBuffer so TypeScript knows the backing store is an ArrayBuffer (not ArrayBufferLike) + const buffer = new ArrayBuffer(rawData.length); + const outputArray = new Uint8Array(buffer); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +}; + +const updateButtonState = (button: HTMLButtonElement, subscribed: boolean): void => { + if (subscribed) { + button.textContent = button.dataset.labelDisable || 'Disable push notifications'; + button.classList.remove('btn-primary'); + button.classList.add('btn-outline-secondary'); + } else { + button.textContent = button.dataset.labelEnable || 'Enable push notifications'; + button.classList.remove('btn-outline-secondary'); + button.classList.add('btn-primary'); + } +}; + +const showToast = (message: string, type: 'success' | 'danger'): void => { + const container = document.getElementById('pmf-push-toast-container'); + if (!container) { + return; + } + + const messageNode = document.createTextNode(message); + + const closeButton = addElement('button', { + type: 'button', + className: 'btn-close', + 'data-bs-dismiss': 'alert', + ariaLabel: 'Close', + }); + + const alert = addElement( + 'div', + { + className: `alert alert-${type} alert-dismissible fade show`, + role: 'alert', + }, + [messageNode, closeButton] + ); + + container.appendChild(alert); + + setTimeout(() => { + alert.classList.remove('show'); + setTimeout(() => alert.remove(), 150); + }, 4000); +}; + +const subscribeUser = async ( + registration: ServiceWorkerRegistration, + vapidPublicKey: string +): Promise => { + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as unknown as BufferSource, + }); + + await subscribePush(subscription); + return subscription; +}; + +/** + * Handles the global push notification banner shown on all pages. + */ +const handlePushBanner = async ( + registration: ServiceWorkerRegistration, + vapidPublicKey: string, + isSubscribed: boolean +): Promise => { + const banner = document.getElementById('pmf-push-banner'); + if (!banner || isSubscribed) { + return; + } + + if (!('Notification' in window) || Notification.permission === 'denied') { + return; + } + + if (Notification.permission === 'granted') { + // Already granted but not subscribed — might have been cleared. Don't nag. + return; + } + + // Check if the user has dismissed the banner before + if (localStorage.getItem(PUSH_DISMISSED_KEY)) { + return; + } + + // Show the banner + banner.classList.remove('d-none'); + + const enableButton = document.getElementById('pmf-push-banner-enable') as HTMLButtonElement | null; + const dismissButton = document.getElementById('pmf-push-banner-dismiss') as HTMLButtonElement | null; + + enableButton?.addEventListener('click', async (): Promise => { + try { + await subscribeUser(registration, vapidPublicKey); + banner.classList.add('d-none'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'subscribed'); + } catch (error) { + console.error('Push subscription error:', error); + banner.classList.add('d-none'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'denied'); + } + }); + + dismissButton?.addEventListener('click', (): void => { + banner.classList.add('d-none'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'dismissed'); + }); +}; + +/** + * Handles the UCP push notification toggle button. + */ +const handleUcpToggle = async ( + button: HTMLButtonElement, + registration: ServiceWorkerRegistration, + vapidPublicKey: string, + initialSubscription: PushSubscription | null +): Promise => { + let isSubscribed = initialSubscription !== null; + let currentSubscription = initialSubscription; + updateButtonState(button, isSubscribed); + button.disabled = false; + + button.addEventListener('click', async (): Promise => { + button.disabled = true; + + try { + if (isSubscribed && currentSubscription) { + const endpoint = currentSubscription.endpoint; + await currentSubscription.unsubscribe(); + await unsubscribePush(endpoint); + isSubscribed = false; + currentSubscription = null; + updateButtonState(button, false); + showToast(button.dataset.msgDisabled || 'Push notifications disabled', 'success'); + localStorage.removeItem(PUSH_DISMISSED_KEY); + } else { + if (Notification.permission === 'denied') { + showToast(button.dataset.msgPermissionDenied || 'Push notification permission was denied', 'danger'); + button.disabled = false; + return; + } + + currentSubscription = await subscribeUser(registration, vapidPublicKey); + isSubscribed = true; + updateButtonState(button, true); + showToast(button.dataset.msgEnabled || 'Push notifications enabled', 'success'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'subscribed'); + } + } catch (error) { + console.error('Push subscription error:', error); + // Show more specific error message + let errorMessage = button.dataset.msgError || 'Failed to enable push notifications'; + if (error instanceof Error) { + if (error.message.includes('permission') || error.name === 'NotAllowedError') { + errorMessage = button.dataset.msgPermissionDenied || 'Push notification permission was denied'; + } else { + errorMessage = error.message; + } + } + showToast(errorMessage, 'danger'); + } + + button.disabled = false; + }); +}; + +export const handlePushNotifications = async (): Promise => { + const ucpButton = document.getElementById('pmf-push-toggle') as HTMLButtonElement | null; + const banner = document.getElementById('pmf-push-banner'); + + // Nothing to do if neither the UCP button nor the global banner exist + if (!ucpButton && !banner) { + return; + } + + // For the banner only (no UCP button): skip API call if user already dismissed/subscribed + // This avoids unnecessary API calls on every page load + if (!ucpButton && banner) { + if (localStorage.getItem(PUSH_DISMISSED_KEY)) { + return; + } + // Also skip if notifications are already granted (user is subscribed) + if ('Notification' in window && Notification.permission === 'granted') { + return; + } + // Skip if permission was denied + if ('Notification' in window && Notification.permission === 'denied') { + return; + } + } + + // Service workers require a secure context (HTTPS or localhost) + if (!window.isSecureContext) { + banner?.classList.add('d-none'); + return; + } + + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + if (ucpButton) { + ucpButton.disabled = true; + ucpButton.textContent = + ucpButton.dataset.labelNotSupported || 'Push notifications are not supported by your browser'; + } + banner?.classList.add('d-none'); + return; + } + + try { + const vapidResponse = await getVapidPublicKey(); + + if (!vapidResponse.enabled || !vapidResponse.vapidPublicKey) { + if (ucpButton) { + ucpButton.parentElement?.parentElement?.classList.add('d-none'); + } + banner?.classList.add('d-none'); + return; + } + + // Register service worker - compute URL from base href to support subdirectory installs + const baseElement = document.querySelector('base'); + const baseHref = baseElement?.getAttribute('href') ?? '/'; + const swUrl = new URL('sw.js', baseHref).href; + const registration = await navigator.serviceWorker.register(swUrl); + const existingSubscription = await registration.pushManager.getSubscription(); + const isSubscribed = existingSubscription !== null; + + // Handle UCP toggle button (on the User Control Panel page) + if (ucpButton) { + await handleUcpToggle(ucpButton, registration, vapidResponse.vapidPublicKey, existingSubscription); + } + + // Handle global push banner (on all pages) + if (banner) { + await handlePushBanner(registration, vapidResponse.vapidPublicKey, isSubscribed); + } + } catch (error) { + console.error('Push notification setup failed:', error); + if (ucpButton) { + ucpButton.disabled = true; + } + banner?.classList.add('d-none'); + } +}; diff --git a/phpmyfaq/assets/templates/admin/configuration/forms.twig b/phpmyfaq/assets/templates/admin/configuration/forms.twig index 0bb647f2a4..22595b175f 100644 --- a/phpmyfaq/assets/templates/admin/configuration/forms.twig +++ b/phpmyfaq/assets/templates/admin/configuration/forms.twig @@ -100,7 +100,7 @@ {% endif %} - + diff --git a/phpmyfaq/assets/templates/admin/configuration/main.twig b/phpmyfaq/assets/templates/admin/configuration/main.twig index 252a0ddc08..183b39617f 100644 --- a/phpmyfaq/assets/templates/admin/configuration/main.twig +++ b/phpmyfaq/assets/templates/admin/configuration/main.twig @@ -24,80 +24,121 @@
    -
    -
    -
    - -